add project

This commit is contained in:
ZOMBIIIIIII
2026-04-08 14:11:27 +03:00
parent bfa95223a0
commit a81e29807c
115 changed files with 18413 additions and 0 deletions

28
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,28 @@
import { webEnv } from './env';
const API_URL = webEnv.apiUrl;
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
const res = await fetch(`${API_URL}${path}`, {
...options,
headers,
credentials: 'include',
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Request failed');
}
return data.data;
}
export const api = {
getWallets: () => request<any>('/api/wallets'),
};

View File

@@ -0,0 +1,185 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
const BSC_CHAIN_ID = 56;
const BSC_BALANCE_TIMEOUT_MS = 6_000;
const BSC_RPC_HEALTHCHECK_TIMEOUT_MS = 4_000;
const BEP20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
const BSC_RPC_CANDIDATES = dedupeUrls([
webEnv.bscRpcUrl,
'https://bsc-dataseed1.defibit.io',
'https://bsc-dataseed1.ninicoin.io',
'https://bsc-dataseed.binance.org',
]);
const BSC_TOKENS: TokenDefinition[] = [
{
chain: 'BSC',
symbol: 'BNB',
decimals: 18,
contractAddress: 'native',
coinGeckoId: 'binancecoin',
isNative: true,
},
{
chain: 'BSC',
symbol: 'USDT',
decimals: 18,
contractAddress: '0x55d398326f99059fF775485246999027B3197955',
coinGeckoId: 'tether',
isNative: false,
},
{
chain: 'BSC',
symbol: 'DOGE',
decimals: 8,
contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
coinGeckoId: 'dogecoin',
isNative: false,
},
];
export async function fetchBscBalances(address: string): Promise<ChainBalance> {
let provider: ethers.providers.StaticJsonRpcProvider;
try {
provider = await getHealthyBscProvider();
} catch (error) {
return {
chain: 'BSC',
address,
tokens: BSC_TOKENS.map(createEmptyTokenBalance),
totalUsd: null,
error: getErrorMessage(error),
};
}
const settled = await Promise.allSettled(
BSC_TOKENS.map(async (token) => readBscTokenBalance(provider, address, token))
);
const tokens: TokenBalance[] = [];
const errors: Array<{ symbol: string; message: string }> = [];
settled.forEach((result, index) => {
const token = BSC_TOKENS[index];
if (result.status === 'fulfilled') {
tokens.push(result.value);
return;
}
tokens.push(createEmptyTokenBalance(token));
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
});
const uniqueMessages = [...new Set(errors.map((item) => item.message))];
const error =
errors.length === BSC_TOKENS.length && uniqueMessages.length === 1
? uniqueMessages[0]
: errors.length
? errors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
: null;
return {
chain: 'BSC',
address,
tokens,
totalUsd: null,
error,
};
}
async function getHealthyBscProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
let lastError: unknown = new Error('No BSC RPC endpoints configured');
for (const rpcUrl of BSC_RPC_CANDIDATES) {
const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, BSC_CHAIN_ID);
try {
await withTimeout(
provider.getBlockNumber(),
BSC_RPC_HEALTHCHECK_TIMEOUT_MS,
`BSC RPC health-check timed out for ${rpcUrl}`
);
return provider;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
async function readBscTokenBalance(provider: ethers.providers.StaticJsonRpcProvider, address: string, token: TokenDefinition): Promise<TokenBalance> {
if (token.isNative) {
const balance = await withTimeout(
provider.getBalance(address),
BSC_BALANCE_TIMEOUT_MS,
'BNB balance request timed out'
);
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatEther(balance),
priceUsd: null,
valueUsd: null,
};
}
const contract = new ethers.Contract(token.contractAddress, BEP20_ABI, provider);
const balance = (await withTimeout(
contract.balanceOf(address),
BSC_BALANCE_TIMEOUT_MS,
`${token.symbol} balance request timed out`
)) as ethers.BigNumber;
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatUnits(balance, token.decimals),
priceUsd: null,
valueUsd: null,
};
}
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
return {
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
};
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
if (error.message.includes('Failed to fetch')) {
return 'BSC RPC is temporarily unavailable';
}
return error.message;
}
return 'Unable to load token balance';
}
function dedupeUrls(urls: string[]): string[] {
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
}

View File

@@ -0,0 +1,101 @@
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenDefinition } from './types';
const BTC_BALANCE_TIMEOUT_MS = 12_000;
const BTC_TOKEN: TokenDefinition = {
chain: 'BTC',
symbol: 'BTC',
decimals: 8,
contractAddress: 'native',
coinGeckoId: 'bitcoin',
isNative: true,
};
interface BlockstreamAddressResponse {
chain_stats?: {
funded_txo_sum?: number;
spent_txo_sum?: number;
};
}
export async function fetchBtcBalances(address: string): Promise<ChainBalance> {
try {
const response = await fetchJsonWithTimeout<BlockstreamAddressResponse>(
`${webEnv.btcApiUrl}/address/${address}`,
BTC_BALANCE_TIMEOUT_MS
);
const funded = response.chain_stats?.funded_txo_sum ?? 0;
const spent = response.chain_stats?.spent_txo_sum ?? 0;
const sats = Math.max(funded - spent, 0);
return {
chain: 'BTC',
address,
tokens: [
{
...BTC_TOKEN,
balanceRaw: sats.toString(),
balanceFormatted: formatFixedBalance(sats, BTC_TOKEN.decimals),
priceUsd: null,
valueUsd: null,
},
],
totalUsd: null,
error: null,
};
} catch (error) {
return {
chain: 'BTC',
address,
tokens: [
{
...BTC_TOKEN,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
},
],
totalUsd: null,
error: getErrorMessage(error),
};
}
}
async function fetchJsonWithTimeout<T>(url: string, timeoutMs: number): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`BTC API returned ${response.status}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeoutId);
}
}
function formatFixedBalance(rawValue: number, decimals: number): string {
if (rawValue === 0) {
return '0';
}
return (rawValue / 10 ** decimals).toString();
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return 'Unable to load BTC balance';
}

View File

@@ -0,0 +1,257 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
const ETH_CHAIN_ID = 1;
const ETH_BALANCE_TIMEOUT_MS = 6_000;
const ETH_RPC_HEALTHCHECK_TIMEOUT_MS = 4_000;
const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
const ETH_RPC_CANDIDATES = dedupeUrls([
webEnv.ethRpcUrl,
'https://ethereum-rpc.publicnode.com',
'https://rpc.ankr.com/eth',
'https://eth.llamarpc.com',
]);
const ETH_TOKENS: TokenDefinition[] = [
{
chain: 'ETH',
symbol: 'ETH',
decimals: 18,
contractAddress: 'native',
coinGeckoId: 'ethereum',
isNative: true,
},
{
chain: 'ETH',
symbol: 'USDT',
decimals: 6,
contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
coinGeckoId: 'tether',
isNative: false,
},
{
chain: 'ETH',
symbol: 'USDC',
decimals: 6,
contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
coinGeckoId: 'usd-coin',
isNative: false,
},
{
chain: 'ETH',
symbol: 'XAUT',
decimals: 6,
contractAddress: '0x68749665FF8D2d112Fa859AA293F07A622782F38',
coinGeckoId: 'tether-gold',
isNative: false,
},
{
chain: 'ETH',
symbol: 'UNI',
decimals: 18,
contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984',
coinGeckoId: 'uniswap',
isNative: false,
},
{
chain: 'ETH',
symbol: 'PEPE',
decimals: 18,
contractAddress: '0x6982508145454Ce325dDbE47a25d4ec3d2311933',
coinGeckoId: 'pepe',
isNative: false,
},
{
chain: 'ETH',
symbol: 'stETH',
decimals: 18,
contractAddress: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84',
coinGeckoId: 'staked-ether',
isNative: false,
},
{
chain: 'ETH',
symbol: 'SHIB',
decimals: 18,
contractAddress: '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE',
coinGeckoId: 'shiba-inu',
isNative: false,
},
{
chain: 'ETH',
symbol: 'LINK',
decimals: 18,
contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA',
coinGeckoId: 'chainlink',
isNative: false,
},
{
chain: 'ETH',
symbol: 'POL',
decimals: 18,
contractAddress: '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6',
coinGeckoId: 'polygon-ecosystem-token',
isNative: false,
},
{
chain: 'ETH',
symbol: 'WLFI',
decimals: 18,
contractAddress: '0x66f85E3865D0cFDC009acf6280a8621f12e46CCf',
coinGeckoId: 'world-liberty-financial',
isNative: false,
},
{
chain: 'ETH',
symbol: 'AAVE',
decimals: 18,
contractAddress: '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9',
coinGeckoId: 'aave',
isNative: false,
},
];
export async function fetchEthBalances(address: string): Promise<ChainBalance> {
let provider: ethers.providers.StaticJsonRpcProvider;
try {
provider = await getHealthyEthProvider();
} catch (error) {
return {
chain: 'ETH',
address,
tokens: ETH_TOKENS.map(createEmptyTokenBalance),
totalUsd: null,
error: getErrorMessage(error),
};
}
const settled = await Promise.allSettled(
ETH_TOKENS.map(async (token) => readEthTokenBalance(provider, address, token))
);
const tokens: TokenBalance[] = [];
const errors: Array<{ symbol: string; message: string }> = [];
settled.forEach((result, index) => {
const token = ETH_TOKENS[index];
if (result.status === 'fulfilled') {
tokens.push(result.value);
return;
}
tokens.push(createEmptyTokenBalance(token));
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
});
const uniqueMessages = [...new Set(errors.map((item) => item.message))];
const error =
errors.length === ETH_TOKENS.length && uniqueMessages.length === 1
? uniqueMessages[0]
: errors.length
? errors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
: null;
return {
chain: 'ETH',
address,
tokens,
totalUsd: null,
error,
};
}
async function getHealthyEthProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
let lastError: unknown = new Error('No Ethereum RPC endpoints configured');
for (const rpcUrl of ETH_RPC_CANDIDATES) {
const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, ETH_CHAIN_ID);
try {
await withTimeout(
provider.getBlockNumber(),
ETH_RPC_HEALTHCHECK_TIMEOUT_MS,
`ETH RPC health-check timed out for ${rpcUrl}`
);
return provider;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
async function readEthTokenBalance(provider: ethers.providers.StaticJsonRpcProvider, address: string, token: TokenDefinition): Promise<TokenBalance> {
if (token.isNative) {
const balance = await withTimeout(
provider.getBalance(address),
ETH_BALANCE_TIMEOUT_MS,
'ETH balance request timed out'
);
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatEther(balance),
priceUsd: null,
valueUsd: null,
};
}
const contract = new ethers.Contract(token.contractAddress, ERC20_ABI, provider);
const balance = (await withTimeout(
contract.balanceOf(address),
ETH_BALANCE_TIMEOUT_MS,
`${token.symbol} balance request timed out`
)) as ethers.BigNumber;
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatUnits(balance, token.decimals),
priceUsd: null,
valueUsd: null,
};
}
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
return {
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
};
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
if (error.message.includes('Failed to fetch')) {
return 'Ethereum RPC is temporarily unavailable';
}
return error.message;
}
return 'Unable to load token balance';
}
function dedupeUrls(urls: string[]): string[] {
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
}

View File

@@ -0,0 +1,120 @@
import type { DerivedWallet } from '@/lib/crypto/derive-keys';
import { fetchBtcBalances } from './btc-balances';
import { fetchEthBalances } from './eth-balances';
import { fetchUsdPrices } from './prices';
import { fetchSolBalances } from './sol-balances';
import { fetchTrxBalances } from './trx-balances';
import { fetchBscBalances } from './bsc-balances';
import type { BalanceChain, ChainBalance, PortfolioBalance, TokenBalance } from './types';
const SUPPORTED_CHAINS: BalanceChain[] = ['ETH', 'BTC', 'SOL', 'TRX', 'BSC'];
const balanceFetchers: Record<BalanceChain, (address: string) => Promise<ChainBalance>> = {
ETH: fetchEthBalances,
BTC: fetchBtcBalances,
SOL: fetchSolBalances,
TRX: fetchTrxBalances,
BSC: fetchBscBalances,
};
export async function fetchAllBalances(wallets: DerivedWallet[]): Promise<PortfolioBalance> {
const settled = await Promise.allSettled(
SUPPORTED_CHAINS.map(async (chain) => {
const wallet = wallets.find((item) => item.chain === chain);
if (!wallet) {
return createMissingChainBalance(chain);
}
return balanceFetchers[chain](wallet.address);
})
);
const rawChains = settled.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
}
return createMissingChainBalance(SUPPORTED_CHAINS[index], getErrorMessage(result.reason));
});
let prices: Record<string, number> = {};
let priceError: string | null = null;
try {
const coinIds = rawChains.flatMap((chain) => chain.tokens.map((token) => token.coinGeckoId));
prices = await fetchUsdPrices(coinIds);
} catch (error) {
priceError = getErrorMessage(error);
}
const chains = rawChains.map((chain) => enrichChain(chain, prices));
return {
chains,
totalUsd: sumNullable(chains.map((chain) => chain.totalUsd)),
errors: chains.reduce<PortfolioBalance['errors']>((acc, chain) => {
if (chain.error && chain.error !== '__transient__') {
acc[chain.chain] = chain.error;
}
return acc;
}, {}),
priceError,
updatedAt: new Date().toISOString(),
};
}
function enrichChain(chain: ChainBalance, prices: Record<string, number>): ChainBalance {
const tokens = chain.tokens.map((token) => enrichToken(token, prices));
return {
...chain,
tokens,
totalUsd: sumNullable(tokens.map((token) => token.valueUsd)),
};
}
function enrichToken(token: TokenBalance, prices: Record<string, number>): TokenBalance {
const priceUsd = prices[token.coinGeckoId];
if (typeof priceUsd !== 'number') {
return token;
}
const balance = Number(token.balanceFormatted);
const valueUsd = Number.isFinite(balance) ? balance * priceUsd : null;
return {
...token,
priceUsd,
valueUsd,
};
}
function createMissingChainBalance(chain: BalanceChain, error = 'Wallet not available'): ChainBalance {
return {
chain,
address: '',
tokens: [],
totalUsd: null,
error,
};
}
function sumNullable(values: Array<number | null>): number | null {
const filtered = values.filter((value): value is number => typeof value === 'number');
if (!filtered.length) {
return null;
}
return filtered.reduce((total, value) => total + value, 0);
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return 'Unable to load balances';
}

View File

@@ -0,0 +1,70 @@
const PRICE_CACHE_TTL_MS = 60_000;
const PRICE_REQUEST_TIMEOUT_MS = 10_000;
let cachedPrices: Record<string, number> | null = null;
let cachedAt = 0;
interface CoinGeckoPriceResponse {
[coinId: string]: {
usd?: number;
};
}
export async function fetchUsdPrices(coinIds: string[]): Promise<Record<string, number>> {
const uniqueCoinIds = Array.from(new Set(coinIds.filter(Boolean)));
if (!uniqueCoinIds.length) {
return {};
}
if (
cachedPrices &&
Date.now() - cachedAt < PRICE_CACHE_TTL_MS &&
uniqueCoinIds.every((coinId) => coinId in cachedPrices!)
) {
return uniqueCoinIds.reduce<Record<string, number>>((acc, coinId) => {
acc[coinId] = cachedPrices![coinId];
return acc;
}, {});
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), PRICE_REQUEST_TIMEOUT_MS);
try {
const url = new URL('https://api.coingecko.com/api/v3/simple/price');
url.searchParams.set('ids', uniqueCoinIds.join(','));
url.searchParams.set('vs_currencies', 'usd');
const response = await fetch(url.toString(), {
signal: controller.signal,
cache: 'no-store',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`CoinGecko returned ${response.status}`);
}
const payload = (await response.json()) as CoinGeckoPriceResponse;
const prices = uniqueCoinIds.reduce<Record<string, number>>((acc, coinId) => {
const price = payload[coinId]?.usd;
if (typeof price === 'number') {
acc[coinId] = price;
}
return acc;
}, {});
cachedPrices = {
...(cachedPrices ?? {}),
...prices,
};
cachedAt = Date.now();
return prices;
} finally {
clearTimeout(timeoutId);
}
}

View File

@@ -0,0 +1,325 @@
import { Connection, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js';
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
const SOL_BALANCE_TIMEOUT_MS = 6_000;
const SOL_RPC_CANDIDATES = dedupeUrls([
webEnv.solRpcUrl,
'https://solana.publicnode.com',
'https://api.mainnet-beta.solana.com',
]);
const SOL_TOKENS: TokenDefinition[] = [
{
chain: 'SOL',
symbol: 'SOL',
decimals: 9,
contractAddress: 'native',
coinGeckoId: 'solana',
isNative: true,
},
{
chain: 'SOL',
symbol: 'USDT',
decimals: 6,
contractAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
coinGeckoId: 'tether',
isNative: false,
},
{
chain: 'SOL',
symbol: 'USDC',
decimals: 6,
contractAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
coinGeckoId: 'usd-coin',
isNative: false,
},
{
chain: 'SOL',
symbol: 'PUMP',
decimals: 6,
contractAddress: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn',
coinGeckoId: 'pump',
isNative: false,
},
{
chain: 'SOL',
symbol: 'JUP',
decimals: 6,
contractAddress: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN',
coinGeckoId: 'jupiter-exchange-solana',
isNative: false,
},
{
chain: 'SOL',
symbol: 'WIF',
decimals: 6,
contractAddress: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm',
coinGeckoId: 'dogwifcoin',
isNative: false,
},
{
chain: 'SOL',
symbol: 'POPCAT',
decimals: 9,
contractAddress: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr',
coinGeckoId: 'popcat',
isNative: false,
},
{
chain: 'SOL',
symbol: 'TRUMP',
decimals: 6,
contractAddress: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN',
coinGeckoId: 'official-trump',
isNative: false,
},
{
chain: 'SOL',
symbol: 'PYTH',
decimals: 6,
contractAddress: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3',
coinGeckoId: 'pyth-network',
isNative: false,
},
{
chain: 'SOL',
symbol: 'JTO',
decimals: 9,
contractAddress: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL',
coinGeckoId: 'jito-governance-token',
isNative: false,
},
{
chain: 'SOL',
symbol: 'W',
decimals: 6,
contractAddress: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ',
coinGeckoId: 'wormhole',
isNative: false,
},
{
chain: 'SOL',
symbol: 'BONK',
decimals: 5,
contractAddress: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
coinGeckoId: 'bonk',
isNative: false,
},
{
chain: 'SOL',
symbol: 'ORCA',
decimals: 6,
contractAddress: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE',
coinGeckoId: 'orca',
isNative: false,
},
{
chain: 'SOL',
symbol: 'PENGU',
decimals: 6,
contractAddress: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv',
coinGeckoId: 'pudgy-penguins',
isNative: false,
},
{
chain: 'SOL',
symbol: 'RAY',
decimals: 6,
contractAddress: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R',
coinGeckoId: 'raydium',
isNative: false,
},
];
export async function fetchSolBalances(address: string): Promise<ChainBalance> {
const owner = new PublicKey(address);
const settled = await Promise.allSettled(
SOL_TOKENS.map(async (token) => readSolTokenWithFallback(owner, token))
);
const tokens: TokenBalance[] = [];
const errors: Array<{ symbol: string; message: string }> = [];
settled.forEach((result, index) => {
const token = SOL_TOKENS[index];
if (result.status === 'fulfilled') {
tokens.push(result.value);
return;
}
tokens.push(createEmptyTokenBalance(token));
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
});
// Separate transient (rate-limit, access restricted) from permanent errors
const permanentErrors = errors.filter((e) => !isTransientError(e.message));
const transientErrors = errors.filter((e) => isTransientError(e.message));
const uniqueMessages = [...new Set(permanentErrors.map((item) => item.message))];
let error: string | null =
permanentErrors.length === SOL_TOKENS.length && uniqueMessages.length === 1
? uniqueMessages[0]
: permanentErrors.length
? permanentErrors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
: null;
// If some/all errors were transient, mark chain so store keeps previous balances
// '__transient__' is an internal marker — not displayed in UI
if (!error && transientErrors.length > 0) {
error = '__transient__';
}
return {
chain: 'SOL',
address,
tokens,
totalUsd: null,
error,
};
}
async function readSolTokenWithFallback(
owner: PublicKey,
token: TokenDefinition
): Promise<TokenBalance> {
let lastError: unknown;
for (const rpcUrl of SOL_RPC_CANDIDATES) {
try {
const connection = new Connection(rpcUrl, 'confirmed');
return await readSolTokenBalance(connection, owner, token);
} catch (error) {
lastError = error;
if (isMintNotFoundError(error)) {
return createEmptyTokenBalance(token);
}
}
}
throw lastError;
}
async function readSolTokenBalance(
connection: Connection,
owner: PublicKey,
token: TokenDefinition
): Promise<TokenBalance> {
if (token.isNative) {
const lamports = await withTimeout(
connection.getBalance(owner),
SOL_BALANCE_TIMEOUT_MS,
'SOL balance request timed out'
);
return {
...token,
balanceRaw: lamports.toString(),
balanceFormatted: (lamports / LAMPORTS_PER_SOL).toString(),
priceUsd: null,
valueUsd: null,
};
}
const mint = new PublicKey(token.contractAddress);
const response = await withTimeout(
connection.getParsedTokenAccountsByOwner(owner, { mint }),
SOL_BALANCE_TIMEOUT_MS,
`${token.symbol} balance request timed out`
);
const amountRaw = response.value.reduce((sum, account) => {
const parsed = account.account.data.parsed;
const amount = parsed.info.tokenAmount.amount;
return sum + BigInt(amount);
}, 0n);
return {
...token,
balanceRaw: amountRaw.toString(),
balanceFormatted: formatBigIntBalance(amountRaw, token.decimals),
priceUsd: null,
valueUsd: null,
};
}
function isMintNotFoundError(error: unknown): boolean {
if (error instanceof Error) {
const msg = error.message;
return msg.includes('could not find mint') || msg.includes('Invalid param');
}
return false;
}
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
return {
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
};
}
function formatBigIntBalance(rawValue: bigint, decimals: number): string {
if (rawValue === 0n) {
return '0';
}
const divisor = 10n ** BigInt(decimals);
const whole = rawValue / divisor;
const fraction = rawValue % divisor;
if (fraction === 0n) {
return whole.toString();
}
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
function isTransientError(msg: string): boolean {
return msg.includes('access restricted') ||
msg.includes('temporarily unavailable') ||
msg.includes('timed out') ||
msg.includes('rate') ||
msg.includes('429') ||
msg.includes('403');
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
const msg = error.message;
if (msg.includes('403') || msg.includes('API key is not allowed')) {
return 'Solana RPC access restricted';
}
if (msg.includes('Failed to fetch')) {
return 'Solana RPC is temporarily unavailable';
}
return msg;
}
return 'Unable to load SOL balance';
}
function dedupeUrls(urls: string[]): string[] {
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
}

View File

@@ -0,0 +1,133 @@
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenDefinition } from './types';
const TRX_BALANCE_TIMEOUT_MS = 12_000;
const TRX_TOKENS: TokenDefinition[] = [
{
chain: 'TRX',
symbol: 'TRX',
decimals: 6,
contractAddress: 'native',
coinGeckoId: 'tron',
isNative: true,
},
{
chain: 'TRX',
symbol: 'USDT',
decimals: 6,
contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
coinGeckoId: 'tether',
isNative: false,
},
];
interface TronGridAccountResponse {
data?: Array<{
balance?: number;
trc20?: Array<Record<string, string>>;
}>;
}
export async function fetchTrxBalances(address: string): Promise<ChainBalance> {
try {
const response = await fetchJsonWithTimeout<TronGridAccountResponse>(
`${webEnv.apiUrl}/api/tron/account/${address}`,
TRX_BALANCE_TIMEOUT_MS
);
const account = response.data?.[0];
const nativeRaw = account?.balance ?? 0;
const trc20Balances = account?.trc20 ?? [];
const usdtRaw = trc20Balances.reduce((current, entry) => {
const next = entry[TRX_TOKENS[1].contractAddress];
return next ?? current;
}, '0');
return {
chain: 'TRX',
address,
tokens: [
{
...TRX_TOKENS[0],
balanceRaw: nativeRaw.toString(),
balanceFormatted: formatBalance(nativeRaw.toString(), TRX_TOKENS[0].decimals),
priceUsd: null,
valueUsd: null,
},
{
...TRX_TOKENS[1],
balanceRaw: usdtRaw,
balanceFormatted: formatBalance(usdtRaw, TRX_TOKENS[1].decimals),
priceUsd: null,
valueUsd: null,
},
],
totalUsd: null,
error: null,
};
} catch (error) {
console.warn(`[TRX] balance fetch failed:`, error);
return {
chain: 'TRX',
address,
tokens: TRX_TOKENS.map((token) => ({
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
})),
totalUsd: null,
error: getErrorMessage(error),
};
}
}
async function fetchJsonWithTimeout<T>(url: string, timeoutMs: number): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`TRON API returned ${response.status}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeoutId);
}
}
function formatBalance(rawValue: string, decimals: number): string {
const bigintValue = BigInt(rawValue || '0');
if (bigintValue === 0n) {
return '0';
}
const divisor = 10n ** BigInt(decimals);
const whole = bigintValue / divisor;
const fraction = bigintValue % divisor;
if (fraction === 0n) {
return whole.toString();
}
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return 'Unable to load TRX balance';
}

View File

@@ -0,0 +1,33 @@
export type BalanceChain = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
export interface TokenDefinition {
chain: BalanceChain;
symbol: string;
decimals: number;
contractAddress: string | 'native';
coinGeckoId: string;
isNative: boolean;
}
export interface TokenBalance extends TokenDefinition {
balanceRaw: string;
balanceFormatted: string;
priceUsd: number | null;
valueUsd: number | null;
}
export interface ChainBalance {
chain: BalanceChain;
address: string;
tokens: TokenBalance[];
totalUsd: number | null;
error: string | null;
}
export interface PortfolioBalance {
chains: ChainBalance[];
totalUsd: number | null;
errors: Partial<Record<BalanceChain, string>>;
priceError: string | null;
updatedAt: string;
}

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

View 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));
}

View 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+$/, '')}`;
}

View 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';
}

View File

@@ -0,0 +1,4 @@
import { ethers } from 'ethers';
/** Fixed gas price for all BSC transactions (swaps & sends) */
export const BSC_GAS_PRICE = ethers.utils.parseUnits('0.055', 'gwei');

View File

@@ -0,0 +1,17 @@
import { HDKey } from '@scure/bip32';
import * as bitcoin from 'bitcoinjs-lib';
export function deriveBtcWallet(seed: Uint8Array) {
const root = HDKey.fromMasterSeed(seed);
const child = root.derive("m/84'/0'/0'/0/0");
const { address } = bitcoin.payments.p2wpkh({
pubkey: Buffer.from(child.publicKey!),
network: bitcoin.networks.bitcoin,
});
return {
address: address!,
privateKey: Buffer.from(child.privateKey!).toString('hex'),
};
}

View File

@@ -0,0 +1,9 @@
import { ethers } from 'ethers';
export function deriveEthWallet(mnemonicPhrase: string) {
const wallet = ethers.Wallet.fromMnemonic(mnemonicPhrase, "m/44'/60'/0'/0/0");
return {
address: wallet.address,
privateKey: wallet.privateKey,
};
}

View File

@@ -0,0 +1,14 @@
import { generateMnemonic as genMnemonic, mnemonicToSeed, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
export function generateMnemonic(): string {
return genMnemonic(wordlist, 128);
}
export async function mnemonicToSeedBytes(mnemonic: string): Promise<Uint8Array> {
return mnemonicToSeed(mnemonic);
}
export function isValidMnemonic(mnemonic: string): boolean {
return validateMnemonic(mnemonic, wordlist);
}

View File

@@ -0,0 +1,13 @@
import { Keypair } from '@solana/web3.js';
import { derivePath } from 'ed25519-hd-key';
export function deriveSolWallet(seed: Uint8Array) {
const path = "m/44'/501'/0'/0'";
const derived = derivePath(path, Buffer.from(seed).toString('hex'));
const keypair = Keypair.fromSeed(derived.key);
return {
address: keypair.publicKey.toBase58(),
privateKey: Buffer.from(keypair.secretKey).toString('hex'),
};
}

View File

@@ -0,0 +1,58 @@
import { ethers } from 'ethers';
export function deriveTrxWallet(mnemonicPhrase: string) {
const hdNode = ethers.utils.HDNode.fromMnemonic(mnemonicPhrase).derivePath("m/44'/195'/0'/0/0");
const ethAddress = ethers.utils.computeAddress(hdNode.publicKey);
const address = ethToTronAddress(ethAddress);
return {
address,
privateKey: hdNode.privateKey.slice(2),
};
}
function ethToTronAddress(ethAddress: string): string {
const hex = '41' + ethAddress.slice(2);
return hexToBase58Check(hex);
}
function hexToBase58Check(hex: string): string {
const bytes = hexToBytes(hex);
const hash1 = sha256Sync(bytes);
const hash2 = sha256Sync(hash1);
const checksum = hash2.slice(0, 4);
const payload = new Uint8Array(bytes.length + 4);
payload.set(bytes);
payload.set(checksum, bytes.length);
return base58Encode(payload);
}
function hexToBytes(hex: string): Uint8Array {
const arr = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
arr[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return arr;
}
function sha256Sync(data: Uint8Array): Uint8Array {
const { createHash } = require('crypto');
return new Uint8Array(createHash('sha256').update(data).digest());
}
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function base58Encode(data: Uint8Array): string {
let num = BigInt('0x' + Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(''));
let result = '';
while (num > 0n) {
const mod = Number(num % 58n);
result = BASE58_ALPHABET[mod] + result;
num = num / 58n;
}
for (const byte of data) {
if (byte === 0) result = '1' + result;
else break;
}
return result;
}

16
apps/web/src/lib/env.ts Normal file
View File

@@ -0,0 +1,16 @@
function readEnv(name: string, fallback: string): string {
const value = process.env[name];
return value && value.trim() ? value : fallback;
}
function readUrlEnv(name: string, fallback: string): string {
return readEnv(name, fallback).replace(/\/+$/, '');
}
export const webEnv = {
apiUrl: readUrlEnv('NEXT_PUBLIC_API_URL', 'http://localhost:3001'),
ethRpcUrl: readUrlEnv('NEXT_PUBLIC_ETH_RPC_URL', 'https://ethereum-rpc.publicnode.com'),
solRpcUrl: readUrlEnv('NEXT_PUBLIC_SOL_RPC_URL', 'https://solana.publicnode.com'),
btcApiUrl: readUrlEnv('NEXT_PUBLIC_BTC_API_URL', 'https://blockstream.info/api'),
bscRpcUrl: readUrlEnv('NEXT_PUBLIC_BSC_RPC_URL', 'https://bsc-dataseed.binance.org'),
} as const;

View File

@@ -0,0 +1,25 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
const MAINNET_NETWORK = { chainId: 1, name: 'mainnet' } as const;
const ETH_RPC_FALLBACKS = [
'https://ethereum-rpc.publicnode.com',
'https://rpc.ankr.com/eth',
'https://eth.llamarpc.com',
];
function getEthRpcUrls(): string[] {
return [...new Set([webEnv.ethRpcUrl, ...ETH_RPC_FALLBACKS].map((url) => url.trim()).filter(Boolean))];
}
export function createEthProvider(): ethers.providers.FallbackProvider {
const providerConfigs = getEthRpcUrls().map((url, index) => ({
provider: new ethers.providers.StaticJsonRpcProvider(url, MAINNET_NETWORK),
priority: index + 1,
weight: 1,
stallTimeout: 1_200,
}));
return new ethers.providers.FallbackProvider(providerConfigs, 1);
}

View File

@@ -0,0 +1,66 @@
import { ethers } from 'ethers';
import { createEthProvider } from '@/lib/eth-provider';
const FETCH_TIMEOUT_MS = 8_000;
// Fixed small priority tips (gwei) — just enough to get included
const PRIORITY_FEE: Record<'slow' | 'normal' | 'fast', number> = {
slow: 0.01,
normal: 0.015,
fast: 0.03,
};
export interface GasTier {
maxFeePerGas: number;
maxPriorityFeePerGas: number;
confidence: number;
}
export interface GasPriceData {
baseFeeGwei: number;
slow: GasTier;
normal: GasTier;
fast: GasTier;
}
export async function fetchGasPrices(): Promise<GasPriceData> {
const provider = createEthProvider();
const feeData = await withTimeout(
provider.getFeeData(),
FETCH_TIMEOUT_MS,
'ETH fee data request timed out',
);
if (!feeData.lastBaseFeePerGas) {
throw new Error('Could not get base fee from ETH RPC');
}
// Convert wei → gwei as float
const baseFeeGwei = parseFloat(ethers.utils.formatUnits(feeData.lastBaseFeePerGas, 'gwei'));
function buildTier(mode: 'slow' | 'normal' | 'fast', confidence: number): GasTier {
const priority = PRIORITY_FEE[mode];
return {
maxFeePerGas: baseFeeGwei + priority,
maxPriorityFeePerGas: priority,
confidence,
};
}
return {
baseFeeGwei,
slow: buildTier('slow', 70),
normal: buildTier('normal', 90),
fast: buildTier('fast', 99),
};
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => { clearTimeout(timeoutId); resolve(value); })
.catch((error) => { clearTimeout(timeoutId); reject(error); });
});
}

View File

@@ -0,0 +1,146 @@
import { SEND_CHAINS, getDefaultToken, type SendChain } from '@/lib/send/constants';
/** Safely resolve token config, falling back to chain's native token */
function resolveToken(chain: SendChain, token: string) {
const chainCfg = SEND_CHAINS[chain];
return chainCfg.tokens[token] ?? chainCfg.tokens[getDefaultToken(chain)];
}
export interface GenerateQrParams {
chain: SendChain;
token: string;
address: string;
amount?: string;
}
/**
* Generate a standard URI for QR code encoding.
*
* Formats:
* - ETH native: ethereum:<address>[?value=<wei>]
* - ETH ERC20: ethereum:<contract>/transfer?address=<recipient>&uint256=<rawAmount>
* - SOL native: solana:<address>[?amount=<human>]
* - SOL SPL: solana:<address>?spl-token=<mint>[&amount=<human>]
* - TRX native: tron:<address>[?amount=<human>]
* - TRX TRC20: tron:<address>?token=<contract>[&amount=<human>]
* - BTC: bitcoin:<address>[?amount=<human>]
*/
export function generateReceiveUri(params: GenerateQrParams): string {
const { chain, token, address, amount } = params;
switch (chain) {
case 'ETH':
return generateEthUri(address, token, amount);
case 'SOL':
return generateSolUri(address, token, amount);
case 'TRX':
return generateTrxUri(address, token, amount);
case 'BTC':
return generateBtcUri(address, amount);
case 'BSC':
return generateBscUri(address, token, amount);
}
}
// ─── ETH (EIP-681) ───
function generateEthUri(address: string, token: string, amount?: string): string {
const tokenCfg = resolveToken('ETH', token);
// Native ETH
if (!tokenCfg.contractAddress) {
if (amount && Number(amount) > 0) {
const wei = toRawUnits(amount, tokenCfg.decimals);
return `ethereum:${address}?value=${wei}`;
}
return `ethereum:${address}`;
}
// ERC20 — ethereum:<contract>/transfer?address=<to>&uint256=<raw>
const base = `ethereum:${tokenCfg.contractAddress}/transfer?address=${address}`;
if (amount && Number(amount) > 0) {
const raw = toRawUnits(amount, tokenCfg.decimals);
return `${base}&uint256=${raw}`;
}
return base;
}
// ─── SOL (Solana Pay) ───
function generateSolUri(address: string, token: string, amount?: string): string {
const tokenCfg = resolveToken('SOL', token);
const params = new URLSearchParams();
// SPL token
if (tokenCfg.contractAddress) {
params.set('spl-token', tokenCfg.contractAddress);
}
if (amount && Number(amount) > 0) {
params.set('amount', amount);
}
const qs = params.toString();
return `solana:${address}${qs ? '?' + qs : ''}`;
}
// ─── TRX ───
function generateTrxUri(address: string, token: string, amount?: string): string {
const tokenCfg = resolveToken('TRX', token);
const params = new URLSearchParams();
if (tokenCfg.contractAddress) {
params.set('token', tokenCfg.contractAddress);
}
if (amount && Number(amount) > 0) {
params.set('amount', amount);
}
const qs = params.toString();
return `tron:${address}${qs ? '?' + qs : ''}`;
}
// ─── BTC (BIP-21) ───
function generateBtcUri(address: string, amount?: string): string {
if (amount && Number(amount) > 0) {
return `bitcoin:${address}?amount=${amount}`;
}
return `bitcoin:${address}`;
}
// ─── BSC (EIP-681 with @56 chain discriminator) ───
function generateBscUri(address: string, token: string, amount?: string): string {
const tokenCfg = resolveToken('BSC', token);
// Native BNB
if (!tokenCfg.contractAddress) {
if (amount && Number(amount) > 0) {
const wei = toRawUnits(amount, tokenCfg.decimals);
return `ethereum:${address}@56?value=${wei}`;
}
return `ethereum:${address}@56`;
}
// BEP-20 — ethereum:<contract>@56/transfer?address=<to>&uint256=<raw>
const base = `ethereum:${tokenCfg.contractAddress}@56/transfer?address=${address}`;
if (amount && Number(amount) > 0) {
const raw = toRawUnits(amount, tokenCfg.decimals);
return `${base}&uint256=${raw}`;
}
return base;
}
// ─── Utils ───
function toRawUnits(amount: string, decimals: number): string {
const parts = amount.split('.');
const whole = parts[0] || '0';
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction)).toString();
}

View File

@@ -0,0 +1,203 @@
import { CONTRACT_TO_SYMBOL, type SendChain } from '@/lib/send/constants';
import { validateAddress, detectChainFromAddress } from '@/lib/send/validate';
export interface ParsedQrResult {
chain: SendChain | null;
token: string;
address: string;
amount: string | null;
}
/**
* Parse a QR URI into chain, token, address, and optional amount.
*
* Supports:
* - ethereum:<address>?value=<wei>
* - ethereum:<contract>/transfer?address=<to>&uint256=<raw>
* - solana:<address>?amount=<human>&spl-token=<mint>
* - tron:<address>?amount=<human>&token=<contract>
* - bitcoin:<address>?amount=<btc>
* - Raw addresses (auto-detect chain)
*/
export function parseQrUri(uri: string): ParsedQrResult {
const trimmed = uri.trim();
// Detect scheme
if (trimmed.startsWith('ethereum:')) return parseEthUri(trimmed);
if (trimmed.startsWith('solana:')) return parseSolUri(trimmed);
if (trimmed.startsWith('tron:')) return parseTrxUri(trimmed);
if (trimmed.startsWith('bitcoin:')) return parseBtcUri(trimmed);
// No scheme — try to detect chain from raw address
return parseRawAddress(trimmed);
}
// ─── Ethereum (EIP-681) — also handles BSC via @56 chain discriminator ───
function parseEthUri(uri: string): ParsedQrResult {
const withoutScheme = uri.slice('ethereum:'.length);
// Detect chain from @chainId discriminator
const isBsc = withoutScheme.includes('@56');
const chain: SendChain = isBsc ? 'BSC' : 'ETH';
const nativeToken = isBsc ? 'BNB' : 'ETH';
const nativeDecimals = 18;
// Strip @chainId from the URI for easier parsing
const cleaned = withoutScheme.replace(/@56/g, '').replace(/@1/g, '');
// Check for ERC20/BEP20 transfer: <contract>/transfer?address=<to>&uint256=<raw>
const transferMatch = cleaned.match(/^(0x[0-9a-fA-F]{40})\/transfer\?(.+)$/);
if (transferMatch) {
const contract = transferMatch[1];
const params = new URLSearchParams(transferMatch[2]);
const toAddress = params.get('address') || '';
const rawAmount = params.get('uint256');
const known = CONTRACT_TO_SYMBOL[contract.toLowerCase()];
const token = known?.symbol ?? nativeToken;
const decimals = known ? getDecimalsForSymbol(token) : nativeDecimals;
return {
chain,
token,
address: toAddress,
amount: rawAmount ? fromRawUnits(rawAmount, decimals) : null,
};
}
// Native transfer: <address>?value=<wei>
const [addressPart, queryPart] = cleaned.split('?');
const params = queryPart ? new URLSearchParams(queryPart) : null;
const weiValue = params?.get('value');
return {
chain,
token: nativeToken,
address: addressPart || '',
amount: weiValue ? fromRawUnits(weiValue, nativeDecimals) : null,
};
}
function getDecimalsForSymbol(symbol: string): number {
const decimalsMap: Record<string, number> = {
USDT: 6, USDC: 6, XAUT: 6, DOGE: 8,
};
return decimalsMap[symbol] ?? 18;
}
// ─── Solana (Solana Pay) ───
function parseSolUri(uri: string): ParsedQrResult {
const withoutScheme = uri.slice('solana:'.length);
const [addressPart, queryPart] = withoutScheme.split('?');
const params = queryPart ? new URLSearchParams(queryPart) : null;
const splToken = params?.get('spl-token');
const amount = params?.get('amount');
let token = 'SOL';
if (splToken) {
const known = CONTRACT_TO_SYMBOL[splToken];
token = known?.symbol ?? 'SOL';
}
return {
chain: 'SOL',
token,
address: addressPart || '',
amount: amount || null,
};
}
// ─── TRON ───
function parseTrxUri(uri: string): ParsedQrResult {
const withoutScheme = uri.slice('tron:'.length);
const [addressPart, queryPart] = withoutScheme.split('?');
const params = queryPart ? new URLSearchParams(queryPart) : null;
const tokenContract = params?.get('token');
const amount = params?.get('amount');
let token = 'TRX';
if (tokenContract) {
const known = CONTRACT_TO_SYMBOL[tokenContract];
token = known?.symbol ?? 'TRX';
}
return {
chain: 'TRX',
token,
address: addressPart || '',
amount: amount || null,
};
}
// ─── Bitcoin (BIP-21) ───
function parseBtcUri(uri: string): ParsedQrResult {
const withoutScheme = uri.slice('bitcoin:'.length);
const [addressPart, queryPart] = withoutScheme.split('?');
const params = queryPart ? new URLSearchParams(queryPart) : null;
return {
chain: 'BTC',
token: 'BTC',
address: addressPart || '',
amount: params?.get('amount') || null,
};
}
// ─── Raw address (no scheme) ───
function parseRawAddress(address: string): ParsedQrResult {
const chain = detectChainFromAddress(address);
if (chain) {
const validation = validateAddress(chain, address);
if (validation.valid) {
// Default to native token
const nativeTokens: Record<SendChain, string> = {
ETH: 'ETH',
SOL: 'SOL',
TRX: 'TRX',
BTC: 'BTC',
BSC: 'BNB',
};
return {
chain,
token: nativeTokens[chain],
address,
amount: null,
};
}
}
// Could not detect or validate
return {
chain: null,
token: '',
address,
amount: null,
};
}
// ─── Utils ───
function fromRawUnits(raw: string, decimals: number): string {
try {
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+$/, '')}`;
} catch {
return raw;
}
}

View File

@@ -0,0 +1,136 @@
export type SendChain = 'ETH' | 'SOL' | 'TRX' | 'BTC' | 'BSC';
export interface SendTokenConfig {
symbol: string;
contractAddress: string | null; // null = native
decimals: number;
}
export interface SendChainConfig {
key: SendChain;
label: string;
walletChain: string; // auth-store chain key
tokens: Record<string, SendTokenConfig>;
explorerTxUrl: string;
}
export const SEND_CHAINS: Record<SendChain, SendChainConfig> = {
ETH: {
key: 'ETH',
label: 'Ethereum',
walletChain: 'ETH',
tokens: {
ETH: { symbol: 'ETH', contractAddress: null, decimals: 18 },
USDT: { symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 },
USDC: { symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 },
stETH: { symbol: 'stETH', contractAddress: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', decimals: 18 },
SHIB: { symbol: 'SHIB', contractAddress: '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', decimals: 18 },
LINK: { symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18 },
POL: { symbol: 'POL', contractAddress: '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6', decimals: 18 },
WLFI: { symbol: 'WLFI', contractAddress: '0x66f85E3865D0cFDC009acf6280a8621f12e46CCf', decimals: 18 },
AAVE: { symbol: 'AAVE', contractAddress: '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', decimals: 18 },
},
explorerTxUrl: 'https://etherscan.io/tx/',
},
SOL: {
key: 'SOL',
label: 'Solana',
walletChain: 'SOL',
tokens: {
SOL: { symbol: 'SOL', contractAddress: null, decimals: 9 },
USDT: { symbol: 'USDT', contractAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 },
USDC: { symbol: 'USDC', contractAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 },
PUMP: { symbol: 'PUMP', contractAddress: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6 },
JUP: { symbol: 'JUP', contractAddress: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6 },
WIF: { symbol: 'WIF', contractAddress: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6 },
POPCAT: { symbol: 'POPCAT', contractAddress: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9 },
TRUMP: { symbol: 'TRUMP', contractAddress: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6 },
PYTH: { symbol: 'PYTH', contractAddress: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6 },
JTO: { symbol: 'JTO', contractAddress: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9 },
W: { symbol: 'W', contractAddress: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6 },
BONK: { symbol: 'BONK', contractAddress: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5 },
ORCA: { symbol: 'ORCA', contractAddress: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6 },
PENGU: { symbol: 'PENGU', contractAddress: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6 },
RAY: { symbol: 'RAY', contractAddress: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6 },
},
explorerTxUrl: 'https://solscan.io/tx/',
},
TRX: {
key: 'TRX',
label: 'TRON',
walletChain: 'TRX',
tokens: {
TRX: { symbol: 'TRX', contractAddress: null, decimals: 6 },
USDT: { symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 },
},
explorerTxUrl: 'https://tronscan.org/#/transaction/',
},
BTC: {
key: 'BTC',
label: 'Bitcoin',
walletChain: 'BTC',
tokens: {
BTC: { symbol: 'BTC', contractAddress: null, decimals: 8 },
},
explorerTxUrl: 'https://blockstream.info/tx/',
},
BSC: {
key: 'BSC',
label: 'BNB Smart Chain',
walletChain: 'BSC',
tokens: {
BNB: { symbol: 'BNB', contractAddress: null, decimals: 18 },
USDT: { symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 },
DOGE: { symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8 },
},
explorerTxUrl: 'https://bscscan.com/tx/',
},
};
export const SEND_CHAIN_OPTIONS: SendChain[] = ['ETH', 'SOL', 'TRX', 'BTC', 'BSC'];
export function getTokenOptions(chain: SendChain): string[] {
return Object.keys(SEND_CHAINS[chain].tokens);
}
export function getDefaultToken(chain: SendChain): string {
return getTokenOptions(chain)[0];
}
export function getTokenConfig(chain: SendChain, token: string): SendTokenConfig {
const cfg = SEND_CHAINS[chain].tokens[token];
if (!cfg) throw new Error(`Token ${token} not found on ${chain}`);
return cfg;
}
/** Map known contract addresses back to token symbols */
export const CONTRACT_TO_SYMBOL: Record<string, { chain: SendChain; symbol: string }> = {
// ETH
'0xdac17f958d2ee523a2206206994597c13d831ec7': { chain: 'ETH', symbol: 'USDT' },
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { chain: 'ETH', symbol: 'USDC' },
'0xae7ab96520de3a18e5e111b5eaab095312d7fe84': { chain: 'ETH', symbol: 'stETH' },
'0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce': { chain: 'ETH', symbol: 'SHIB' },
'0x514910771af9ca656af840dff83e8264ecf986ca': { chain: 'ETH', symbol: 'LINK' },
'0x455e53cbb86018ac2b8092fdcd39d8444affc3f6': { chain: 'ETH', symbol: 'POL' },
'0x66f85e3865d0cfdc009acf6280a8621f12e46ccf': { chain: 'ETH', symbol: 'WLFI' },
'0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9': { chain: 'ETH', symbol: 'AAVE' },
// SOL
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB': { chain: 'SOL', symbol: 'USDT' },
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': { chain: 'SOL', symbol: 'USDC' },
'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn': { chain: 'SOL', symbol: 'PUMP' },
'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN': { chain: 'SOL', symbol: 'JUP' },
'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm': { chain: 'SOL', symbol: 'WIF' },
'7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr': { chain: 'SOL', symbol: 'POPCAT' },
'6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN': { chain: 'SOL', symbol: 'TRUMP' },
'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3': { chain: 'SOL', symbol: 'PYTH' },
'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL': { chain: 'SOL', symbol: 'JTO' },
'85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ': { chain: 'SOL', symbol: 'W' },
'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263': { chain: 'SOL', symbol: 'BONK' },
'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE': { chain: 'SOL', symbol: 'ORCA' },
'2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv': { chain: 'SOL', symbol: 'PENGU' },
'4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R': { chain: 'SOL', symbol: 'RAY' },
// TRX
'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t': { chain: 'TRX', symbol: 'USDT' },
// BSC
'0xba2ae424d960c26247dd6c32edc70b295c744c43': { chain: 'BSC', symbol: 'DOGE' },
};

View File

@@ -0,0 +1,622 @@
import { ethers } from 'ethers';
import {
Connection,
Keypair,
PublicKey,
SystemProgram,
TransactionMessage,
VersionedTransaction,
TransactionInstruction,
} from '@solana/web3.js';
import * as bitcoin from 'bitcoinjs-lib';
import { ECPairFactory } from 'ecpair';
import * as ecc from 'tiny-secp256k1';
import { createEthProvider } from '@/lib/eth-provider';
import { webEnv } from '@/lib/env';
import { BSC_GAS_PRICE } from '@/lib/crypto/bsc-constants';
import { SEND_CHAINS, getTokenConfig, type SendChain } from './constants';
const ECPair = ECPairFactory(ecc);
const ethProvider = createEthProvider();
// ─── Types ───
export interface SendParams {
chain: SendChain;
token: string;
toAddress: string;
amount: string; // human-readable
privateKey: string;
fromAddress: string;
maxFeeGwei?: string | null; // ETH only
priorityFeeGwei?: string | null; // ETH only
}
export interface SendResult {
hash: string;
explorerUrl: string;
}
// ─── Main dispatcher ───
export async function executeSend(params: SendParams): Promise<SendResult> {
if (!params.amount || Number(params.amount) <= 0) {
throw new Error('Enter a valid amount');
}
switch (params.chain) {
case 'ETH':
return executeEthSend(params);
case 'SOL':
return executeSolSend(params);
case 'TRX':
return executeTrxSend(params);
case 'BTC':
return executeBtcSend(params);
case 'BSC':
return executeBscSend(params);
}
}
// ─── ETH Send ───
async function executeEthSend(params: SendParams): Promise<SendResult> {
const wallet = new ethers.Wallet(params.privateKey, ethProvider);
const tokenCfg = getTokenConfig('ETH', params.token);
let hash: string;
if (!tokenCfg.contractAddress) {
// Native ETH transfer
const value = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
const tx = await wallet.sendTransaction({
to: params.toAddress,
value,
...buildGasOverrides(params.maxFeeGwei, params.priorityFeeGwei),
});
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) throw new Error('ETH transaction reverted');
hash = tx.hash;
} else {
// ERC20 transfer
const rawAmount = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
const erc20 = new ethers.Contract(
tokenCfg.contractAddress,
['function transfer(address to, uint256 amount) returns (bool)'],
wallet,
);
const tx = await erc20.transfer(params.toAddress, rawAmount, {
...buildGasOverrides(params.maxFeeGwei, params.priorityFeeGwei),
});
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) throw new Error('ERC20 transfer reverted');
hash = tx.hash;
}
return {
hash,
explorerUrl: `${SEND_CHAINS.ETH.explorerTxUrl}${hash}`,
};
}
function buildGasOverrides(maxFeeGwei?: string | null, priorityFeeGwei?: string | null) {
if (!maxFeeGwei?.trim()) return {};
return {
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
};
}
// ─── SOL Send ───
const SOL_FEE_BPS = 70n; // 0.7%
const SOL_BPS_DENOMINATOR = 10_000n;
const SOL_FEE_RECIPIENT = new PublicKey('8TQUbkZGL2j48qgJppJ1dxUPVX8ZJx7i6bUcyaKrgDKi');
async function executeSolSend(params: SendParams): Promise<SendResult> {
const connection = new Connection(webEnv.solRpcUrl, 'confirmed');
const keypair = Keypair.fromSecretKey(Buffer.from(params.privateKey, 'hex'));
const tokenCfg = getTokenConfig('SOL', params.token);
let signature: string;
if (!tokenCfg.contractAddress) {
// Native SOL transfer with 0.7% fee
const totalLamports = BigInt(parseAmountToRaw(params.amount, tokenCfg.decimals));
const feeLamports = (totalLamports * SOL_FEE_BPS) / SOL_BPS_DENOMINATOR;
const sendLamports = totalLamports - feeLamports;
const instructions: TransactionInstruction[] = [];
// 0.7% fee to fee wallet
if (feeLamports > 0n) {
instructions.push(
SystemProgram.transfer({
fromPubkey: keypair.publicKey,
toPubkey: SOL_FEE_RECIPIENT,
lamports: feeLamports,
}),
);
}
// 99.3% to recipient
instructions.push(
SystemProgram.transfer({
fromPubkey: keypair.publicKey,
toPubkey: new PublicKey(params.toAddress),
lamports: sendLamports,
}),
);
signature = await buildAndSendSolTx(connection, keypair, instructions);
} else {
// SPL Token transfer with 0.7% fee
const mint = new PublicKey(tokenCfg.contractAddress);
const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');
const fromAta = getAssociatedTokenAddress(keypair.publicKey, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
const toAta = getAssociatedTokenAddress(new PublicKey(params.toAddress), mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
const feeAta = getAssociatedTokenAddress(SOL_FEE_RECIPIENT, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
const totalRaw = BigInt(parseAmountToRaw(params.amount, tokenCfg.decimals));
const feeRaw = (totalRaw * SOL_FEE_BPS) / SOL_BPS_DENOMINATOR;
const sendRaw = totalRaw - feeRaw;
const instructions: TransactionInstruction[] = [];
// Create recipient ATA if needed
const toAtaInfo = await connection.getAccountInfo(toAta);
if (!toAtaInfo) {
instructions.push(
createAssociatedTokenAccountInstruction(
keypair.publicKey, toAta, new PublicKey(params.toAddress), mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
),
);
}
// Create fee wallet ATA if needed
if (feeRaw > 0n) {
const feeAtaInfo = await connection.getAccountInfo(feeAta);
if (!feeAtaInfo) {
instructions.push(
createAssociatedTokenAccountInstruction(
keypair.publicKey, feeAta, SOL_FEE_RECIPIENT, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
),
);
}
// 0.7% fee
instructions.push(
createSplTransferInstruction(fromAta, feeAta, keypair.publicKey, feeRaw, TOKEN_PROGRAM_ID),
);
}
// 99.3% to recipient
instructions.push(
createSplTransferInstruction(fromAta, toAta, keypair.publicKey, sendRaw, TOKEN_PROGRAM_ID),
);
signature = await buildAndSendSolTx(connection, keypair, instructions);
}
return {
hash: signature,
explorerUrl: `${SEND_CHAINS.SOL.explorerTxUrl}${signature}`,
};
}
async function buildAndSendSolTx(
connection: Connection,
keypair: Keypair,
instructions: TransactionInstruction[],
): Promise<string> {
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
const messageV0 = new TransactionMessage({
payerKey: keypair.publicKey,
recentBlockhash: latestBlockhash.blockhash,
instructions,
}).compileToV0Message();
const transaction = new VersionedTransaction(messageV0);
transaction.sign([keypair]);
const signature = await connection.sendRawTransaction(transaction.serialize(), {
skipPreflight: false,
maxRetries: 2,
});
await connection.confirmTransaction(
{
signature,
blockhash: latestBlockhash.blockhash,
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
},
'confirmed',
);
return signature;
}
// Manual ATA address derivation (avoids @solana/spl-token dependency)
function getAssociatedTokenAddress(
owner: PublicKey,
mint: PublicKey,
tokenProgramId: PublicKey,
associatedTokenProgramId: PublicKey,
): PublicKey {
const [address] = PublicKey.findProgramAddressSync(
[owner.toBuffer(), tokenProgramId.toBuffer(), mint.toBuffer()],
associatedTokenProgramId,
);
return address;
}
function createAssociatedTokenAccountInstruction(
payer: PublicKey,
associatedToken: PublicKey,
owner: PublicKey,
mint: PublicKey,
tokenProgramId: PublicKey,
associatedTokenProgramId: PublicKey,
): TransactionInstruction {
return new TransactionInstruction({
programId: associatedTokenProgramId,
keys: [
{ pubkey: payer, isSigner: true, isWritable: true },
{ pubkey: associatedToken, isSigner: false, isWritable: true },
{ pubkey: owner, isSigner: false, isWritable: false },
{ pubkey: mint, isSigner: false, isWritable: false },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: tokenProgramId, isSigner: false, isWritable: false },
],
data: Buffer.alloc(0),
});
}
function createSplTransferInstruction(
source: PublicKey,
destination: PublicKey,
owner: PublicKey,
amount: bigint,
tokenProgramId: PublicKey,
): TransactionInstruction {
// SPL Token Transfer instruction layout: u8 instruction (3) + u64 amount
const data = Buffer.alloc(9);
data.writeUInt8(3, 0); // Transfer instruction index
data.writeBigUInt64LE(amount, 1);
return new TransactionInstruction({
programId: tokenProgramId,
keys: [
{ pubkey: source, isSigner: false, isWritable: true },
{ pubkey: destination, isSigner: false, isWritable: true },
{ pubkey: owner, isSigner: true, isWritable: false },
],
data,
});
}
// ─── TRX Send ───
// ── TRX fee constants ──
const TRX_FEE_BPS = 70n; // 0.7%
const TRX_BPS_DENOMINATOR = 10_000n;
const TRX_FEE_RECIPIENT = 'TYTfrem65362TFyQSARTheeYza1GQA37Ug';
async function executeTrxSend(params: SendParams): Promise<SendResult> {
const signingKey = new ethers.utils.SigningKey('0x' + params.privateKey);
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const tokenCfg = getTokenConfig('TRX', params.token);
const rawTotal = BigInt(parseAmountToRaw(params.amount, tokenCfg.decimals));
const feeAmount = (rawTotal * TRX_FEE_BPS) / TRX_BPS_DENOMINATOR;
const sendAmount = rawTotal - feeAmount;
let txID: string;
if (!tokenCfg.contractAddress) {
// Native TRX: 1) send 0.7% fee, 2) send 99.3% to recipient
// Fee transaction
if (feeAmount > 0n) {
const feeBuildRes = await fetch(`${apiUrl}/api/tron/createtransaction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: params.fromAddress,
to_address: TRX_FEE_RECIPIENT,
amount: Number(feeAmount),
}),
});
if (!feeBuildRes.ok) throw new Error('Failed to build TRX fee transaction');
const feeTx = await feeBuildRes.json();
await signAndBroadcastTrx(signingKey, apiUrl, feeTx);
// Small delay to avoid nonce/bandwidth issues
await new Promise((r) => setTimeout(r, 1500));
}
// Main send transaction
const buildRes = await fetch(`${apiUrl}/api/tron/createtransaction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: params.fromAddress,
to_address: params.toAddress,
amount: Number(sendAmount),
}),
});
if (!buildRes.ok) {
const err = await buildRes.json().catch(() => ({ error: 'Failed to build TRX transaction' }));
throw new Error(err.error || `TRX build failed (${buildRes.status})`);
}
const buildResult = await buildRes.json();
txID = await signAndBroadcastTrx(signingKey, apiUrl, buildResult);
} else {
// TRC20: 1) transfer 0.7% fee to fee wallet, 2) transfer 99.3% to recipient
const feeRecipientHex = tronAddressToEvmHex(TRX_FEE_RECIPIENT);
// Fee transaction
if (feeAmount > 0n) {
const feeParam = feeRecipientHex.padStart(64, '0') + feeAmount.toString(16).padStart(64, '0');
const feeBuildRes = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: params.fromAddress,
contract_address: tokenCfg.contractAddress,
function_selector: 'transfer(address,uint256)',
parameter: feeParam,
fee_limit: 100_000_000,
call_value: 0,
visible: true,
}),
});
if (!feeBuildRes.ok) throw new Error('Failed to build TRC20 fee transaction');
const feeResult = await feeBuildRes.json();
if (!feeResult.transaction?.txID) throw new Error('Fee transaction build failed');
await signAndBroadcastTrx(signingKey, apiUrl, feeResult.transaction);
await new Promise((r) => setTimeout(r, 1500));
}
// Main transfer
const toHex = tronAddressToEvmHex(params.toAddress);
const parameter = toHex.padStart(64, '0') + sendAmount.toString(16).padStart(64, '0');
const buildRes = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: params.fromAddress,
contract_address: tokenCfg.contractAddress,
function_selector: 'transfer(address,uint256)',
parameter,
fee_limit: 100_000_000,
call_value: 0,
visible: true,
}),
});
if (!buildRes.ok) {
const err = await buildRes.json().catch(() => ({ error: 'Failed to build TRC20 transaction' }));
throw new Error(err.error || `TRC20 build failed (${buildRes.status})`);
}
const buildResult = await buildRes.json();
if (!buildResult.transaction?.txID) {
throw new Error(buildResult.result?.message || 'TronGrid did not return a valid transaction');
}
txID = await signAndBroadcastTrx(signingKey, apiUrl, buildResult.transaction);
}
return {
hash: txID,
explorerUrl: `${SEND_CHAINS.TRX.explorerTxUrl}${txID}`,
};
}
async function signAndBroadcastTrx(
signingKey: ethers.utils.SigningKey,
apiUrl: string,
tx: Record<string, any>,
): Promise<string> {
if (!tx.txID) {
throw new Error('Transaction has no txID');
}
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] };
const broadcastRes = await fetch(`${apiUrl}/api/tron/broadcasttransaction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedTx),
});
const result = await broadcastRes.json();
if (!result.result) {
const errorMsg = result.message || result.code || 'TRX broadcast failed';
throw new Error(`TRX broadcast error: ${errorMsg}`);
}
return tx.txID;
}
/** Convert T-address to 20-byte EVM hex (without 41 prefix) */
function tronAddressToEvmHex(address: string): string {
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let num = 0n;
for (const char of address) {
const index = BASE58_ALPHABET.indexOf(char);
if (index === -1) throw new Error('Invalid base58 character');
num = num * 58n + BigInt(index);
}
const hex = num.toString(16).padStart(50, '0');
return hex.slice(2, 42); // skip 41 prefix, take 20 bytes
}
// ─── BTC Send ───
async function executeBtcSend(params: SendParams): Promise<SendResult> {
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
// 1. Fetch UTXOs
const utxoRes = await fetch(`${apiUrl}/api/btc/utxos/${params.fromAddress}`);
if (!utxoRes.ok) throw new Error('Failed to fetch UTXOs');
const utxoData = await utxoRes.json();
const utxos: Array<{ txid: string; vout: number; value: number }> = utxoData.data;
if (!utxos.length) {
throw new Error('No confirmed UTXOs available');
}
// 2. Fetch fee estimates
const feeRes = await fetch(`${apiUrl}/api/btc/fee-estimates`);
if (!feeRes.ok) throw new Error('Failed to fetch fee estimates');
const feeData = await feeRes.json();
const feeRate: number = feeData.data?.normal ?? 5; // sat/vB
// 3. Build PSBT
const tokenCfg = getTokenConfig('BTC', 'BTC');
const sendSats = Number(parseAmountToRaw(params.amount, tokenCfg.decimals));
const keyPair = ECPair.fromPrivateKey(Buffer.from(params.privateKey, 'hex'));
const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });
// Sort UTXOs by value descending, select enough to cover amount + estimated fee
const sorted = [...utxos].sort((a, b) => b.value - a.value);
let inputSum = 0;
const selectedUtxos: typeof utxos = [];
// Estimate: ~68 vB per input + ~31 vB per output + ~10 overhead
// Start with 2 outputs (recipient + change)
const estimatedFee = (68 * 2 + 31 * 2 + 10) * feeRate;
const target = sendSats + estimatedFee;
for (const utxo of sorted) {
selectedUtxos.push(utxo);
inputSum += utxo.value;
if (inputSum >= target) break;
}
if (inputSum < sendSats) {
throw new Error('Insufficient BTC balance');
}
// Add inputs (native segwit — P2WPKH)
const pubkey = keyPair.publicKey;
const p2wpkh = bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin });
for (const utxo of selectedUtxos) {
psbt.addInput({
hash: utxo.txid,
index: utxo.vout,
witnessUtxo: {
script: p2wpkh.output!,
value: BigInt(utxo.value),
},
});
}
// Add recipient output
psbt.addOutput({
address: params.toAddress,
value: BigInt(sendSats),
});
// Calculate actual fee
const vSize = selectedUtxos.length * 68 + 2 * 31 + 10;
const fee = Math.ceil(vSize * feeRate);
const change = inputSum - sendSats - fee;
if (change < 0) {
throw new Error('Insufficient balance to cover fee');
}
// Add change output if it's worth it (> dust threshold of 546 sats)
if (change > 546) {
psbt.addOutput({
address: params.fromAddress,
value: BigInt(change),
});
}
// 4. Sign all inputs
for (let i = 0; i < selectedUtxos.length; i++) {
psbt.signInput(i, keyPair);
}
psbt.finalizeAllInputs();
const hex = psbt.extractTransaction().toHex();
// 5. Broadcast
const broadcastRes = await fetch(`${apiUrl}/api/btc/broadcast`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hex }),
});
const broadcastData = await broadcastRes.json();
if (!broadcastData.success) {
throw new Error(broadcastData.error || 'BTC broadcast failed');
}
const txid = broadcastData.data.txid;
return {
hash: txid,
explorerUrl: `${SEND_CHAINS.BTC.explorerTxUrl}${txid}`,
};
}
// ─── BSC Send ───
async function executeBscSend(params: SendParams): Promise<SendResult> {
const bscProvider = new ethers.providers.StaticJsonRpcProvider(webEnv.bscRpcUrl, 56);
const wallet = new ethers.Wallet(
params.privateKey.startsWith('0x') ? params.privateKey : '0x' + params.privateKey,
bscProvider,
);
const tokenCfg = getTokenConfig('BSC', params.token);
let hash: string;
if (!tokenCfg.contractAddress) {
// Native BNB transfer
const value = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
const tx = await wallet.sendTransaction({ to: params.toAddress, value, gasPrice: BSC_GAS_PRICE });
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) throw new Error('BNB transaction reverted');
hash = tx.hash;
} else {
// BEP-20 transfer
const rawAmount = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
const bep20 = new ethers.Contract(
tokenCfg.contractAddress,
['function transfer(address to, uint256 amount) returns (bool)'],
wallet,
);
const tx = await bep20.transfer(params.toAddress, rawAmount, { gasPrice: BSC_GAS_PRICE });
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) throw new Error('BEP-20 transfer reverted');
hash = tx.hash;
}
return {
hash,
explorerUrl: `${SEND_CHAINS.BSC.explorerTxUrl}${hash}`,
};
}
// ─── Shared utils ───
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);
return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction)).toString();
}

View File

@@ -0,0 +1,100 @@
import { ethers } from 'ethers';
import { PublicKey } from '@solana/web3.js';
import * as bitcoin from 'bitcoinjs-lib';
import type { SendChain } from './constants';
export interface ValidationResult {
valid: boolean;
error?: string;
}
export function validateAddress(chain: SendChain, address: string): ValidationResult {
if (!address || !address.trim()) {
return { valid: false, error: 'Address is required' };
}
const trimmed = address.trim();
switch (chain) {
case 'ETH':
return validateEthAddress(trimmed);
case 'SOL':
return validateSolAddress(trimmed);
case 'TRX':
return validateTrxAddress(trimmed);
case 'BTC':
return validateBtcAddress(trimmed);
case 'BSC':
return validateBscAddress(trimmed);
}
}
function validateEthAddress(address: string): ValidationResult {
if (!ethers.utils.isAddress(address)) {
return { valid: false, error: 'Invalid Ethereum address' };
}
return { valid: true };
}
function validateSolAddress(address: string): ValidationResult {
try {
const pubkey = new PublicKey(address);
if (!PublicKey.isOnCurve(pubkey.toBytes())) {
// Still valid — not all valid addresses are on curve (e.g., PDAs)
}
return { valid: true };
} catch {
return { valid: false, error: 'Invalid Solana address' };
}
}
const TRON_ADDRESS_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
function validateTrxAddress(address: string): ValidationResult {
if (!TRON_ADDRESS_RE.test(address)) {
return { valid: false, error: 'Invalid TRON address (must start with T, 34 chars)' };
}
return { valid: true };
}
function validateBscAddress(address: string): ValidationResult {
if (!ethers.utils.isAddress(address)) {
return { valid: false, error: 'Invalid BSC address' };
}
return { valid: true };
}
function validateBtcAddress(address: string): ValidationResult {
try {
bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
return { valid: true };
} catch {
return { valid: false, error: 'Invalid Bitcoin address' };
}
}
/** Try to detect chain from address format */
export function detectChainFromAddress(address: string): SendChain | null {
const trimmed = address.trim();
// ETH: 0x prefix, 42 chars
if (/^0x[0-9a-fA-F]{40}$/.test(trimmed)) return 'ETH';
// TRX: T prefix, 34 chars base58
if (TRON_ADDRESS_RE.test(trimmed)) return 'TRX';
// BTC: bc1, 1, or 3 prefix
if (/^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}$/.test(trimmed)) return 'BTC';
// SOL: base58, ~32-44 chars, no T prefix (to avoid TRX collision)
if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(trimmed) && !trimmed.startsWith('T')) {
try {
new PublicKey(trimmed);
return 'SOL';
} catch {
// Not a valid SOL address
}
}
return null;
}

View File

@@ -0,0 +1,96 @@
import { ethers } from 'ethers';
import { createEthProvider } from '@/lib/eth-provider';
import {
ERC20_ABI,
SWAP_PROXY_ADDRESS_MAINNET,
SWAP_REQUEST_TIMEOUT_MS,
getSwapToken,
isErc20SwapToken,
type SwapTokenSymbol,
} from './constants';
const provider = createEthProvider();
export interface ApprovalParams {
privateKey: string;
tokenSymbol: SwapTokenSymbol;
amount: string;
maxFeeGwei?: string | null;
priorityFeeGwei?: string | null;
}
export interface ApprovalResult {
approvalNeeded: boolean;
approvalHashes: string[];
}
export async function ensureSwapApproval(params: ApprovalParams): Promise<ApprovalResult> {
if (!isErc20SwapToken(params.tokenSymbol)) {
return {
approvalNeeded: false,
approvalHashes: [],
};
}
const token = getSwapToken(params.tokenSymbol);
const wallet = new ethers.Wallet(params.privateKey, provider);
const tokenContract = new ethers.Contract(token.address, ERC20_ABI, wallet);
const requiredAmount = ethers.utils.parseUnits(params.amount, token.decimals);
const allowance = (await withTimeout(
tokenContract.allowance(wallet.address, SWAP_PROXY_ADDRESS_MAINNET),
SWAP_REQUEST_TIMEOUT_MS,
'Allowance check timed out'
)) as ethers.BigNumber;
if (allowance.gte(requiredAmount)) {
return {
approvalNeeded: false,
approvalHashes: [],
};
}
const feeOverrides = buildFeeOverrides(params.maxFeeGwei, params.priorityFeeGwei);
const approvalHashes: string[] = [];
if (params.tokenSymbol === 'USDT' && !allowance.isZero()) {
const resetTx = await tokenContract.approve(SWAP_PROXY_ADDRESS_MAINNET, 0, feeOverrides);
approvalHashes.push(resetTx.hash);
await resetTx.wait();
}
const approveTx = await tokenContract.approve(SWAP_PROXY_ADDRESS_MAINNET, ethers.constants.MaxUint256, feeOverrides);
approvalHashes.push(approveTx.hash);
await approveTx.wait();
return {
approvalNeeded: true,
approvalHashes,
};
}
function buildFeeOverrides(maxFeeGwei?: string | null, priorityFeeGwei?: string | null) {
if (!maxFeeGwei?.trim()) {
return {};
}
return {
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
};
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}

View File

@@ -0,0 +1,203 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
import { BSC_GAS_PRICE } from '@/lib/crypto/bsc-constants';
import {
BSC_TOKEN_ADDRESSES,
BSC_PLATFORM_FEE_BPS,
FEE_SWAP_ROUTER_BSC,
FEE_RECIPIENT,
WBNB_ADDRESS,
} from '../constants';
export interface BscExecuteSwapParams {
privateKeyHex: string;
from: string;
to: string;
amount: string; // raw amount in smallest unit (full amount including fee)
amountOutMin: string; // raw minimum output
userAddress: string;
}
export interface BscSwapResult {
hash: string;
explorerUrl: string;
approvalHashes: string[];
}
const BSC_CHAIN_ID = 56;
const SMART_ROUTER_V2_ABI = [
'function swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to) returns (uint256)',
];
const FEE_ROUTER_ABI = [
'function swapNativeWithFee(bytes routerCalldata) external payable',
];
const ERC20_ABI = [
'function transfer(address to, uint256 amount) returns (bool)',
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)',
];
export async function executeBscSwap(params: BscExecuteSwapParams): Promise<BscSwapResult> {
const { privateKeyHex, from } = params;
const provider = new ethers.providers.StaticJsonRpcProvider(webEnv.bscRpcUrl, BSC_CHAIN_ID);
const wallet = new ethers.Wallet(privateKeyHex.startsWith('0x') ? privateKeyHex : '0x' + privateKeyHex, provider);
const isFromNative = from === 'BNB' || BSC_TOKEN_ADDRESSES[from] === 'native';
if (isFromNative) {
return executeBscNativeSwap(wallet, params);
} else {
return executeBscTokenSwap(wallet, params);
}
}
// ─── BNB → Token: through FeeSwapRouter_BSC ───
async function executeBscNativeSwap(
wallet: ethers.Wallet,
params: BscExecuteSwapParams,
): Promise<BscSwapResult> {
const { amount, amountOutMin, userAddress, to } = params;
const fullAmount = ethers.BigNumber.from(amount);
// Calculate swap amount (99.3% after 0.7% fee taken by contract)
const feeAmount = fullAmount.mul(BSC_PLATFORM_FEE_BPS).div(10000);
const swapAmount = fullAmount.sub(feeAmount);
const tokenOutAddress = BSC_TOKEN_ADDRESSES[to];
if (!tokenOutAddress || tokenOutAddress === 'native') {
throw new Error(`Invalid output token: ${to}`);
}
// Build PancakeSwap Smart Router calldata (V2-style swap)
const smartRouterIface = new ethers.utils.Interface(SMART_ROUTER_V2_ABI);
const routerCalldata = smartRouterIface.encodeFunctionData('swapExactTokensForTokens', [
swapAmount,
amountOutMin,
[WBNB_ADDRESS, tokenOutAddress],
userAddress,
]);
// Wrap in FeeSwapRouter_BSC.swapNativeWithFee
const feeRouterIface = new ethers.utils.Interface(FEE_ROUTER_ABI);
const txData = feeRouterIface.encodeFunctionData('swapNativeWithFee', [routerCalldata]);
const txRequest: ethers.providers.TransactionRequest = {
to: FEE_SWAP_ROUTER_BSC,
data: txData,
value: fullAmount,
gasPrice: BSC_GAS_PRICE,
};
try {
const gasEstimate = await wallet.estimateGas(txRequest);
txRequest.gasLimit = gasEstimate.mul(120).div(100);
} catch {
txRequest.gasLimit = ethers.BigNumber.from(350_000);
}
const response = await wallet.sendTransaction(txRequest);
const receipt = await response.wait();
if (!receipt || receipt.status !== 1) {
throw new Error('BSC swap transaction reverted');
}
return {
hash: response.hash,
explorerUrl: `https://bscscan.com/tx/${response.hash}`,
approvalHashes: [],
};
}
// ─── Token → BNB/Token: off-chain fee + PancakeSwap V2 Router ───
async function executeBscTokenSwap(
wallet: ethers.Wallet,
params: BscExecuteSwapParams,
): Promise<BscSwapResult> {
const { from, to, amount, amountOutMin, userAddress } = params;
const fullAmount = ethers.BigNumber.from(amount);
// 1. Send 0.7% fee to FEE_RECIPIENT
const feeAmount = fullAmount.mul(BSC_PLATFORM_FEE_BPS).div(10000);
const swapAmount = fullAmount.sub(feeAmount);
const tokenInAddress = BSC_TOKEN_ADDRESSES[from];
if (!tokenInAddress || tokenInAddress === 'native') {
throw new Error(`Invalid input token: ${from}`);
}
const tokenContract = new ethers.Contract(tokenInAddress, ERC20_ABI, wallet);
if (feeAmount.gt(0)) {
const feeTx = await tokenContract.transfer(FEE_RECIPIENT, feeAmount, { gasPrice: BSC_GAS_PRICE });
await feeTx.wait();
}
// 2. Swap remaining via PancakeSwap V2 Router (backend builds calldata)
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const buildResponse = await fetch(`${apiUrl}/api/bsc/swap/build`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
from,
to,
amount: swapAmount.toString(),
amountOutMin,
userAddress,
}),
});
if (!buildResponse.ok) {
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build BSC swap' }));
throw new Error(body.error || `BSC swap build failed (${buildResponse.status})`);
}
const { transactions } = await buildResponse.json();
if (!transactions?.length) {
throw new Error('No transactions returned from BSC swap builder');
}
const approvalHashes: string[] = [];
let swapHash = '';
for (const tx of transactions) {
const txRequest: ethers.providers.TransactionRequest = {
to: tx.to,
data: tx.data,
value: ethers.BigNumber.from(tx.value || '0'),
gasPrice: BSC_GAS_PRICE,
};
try {
const gasEstimate = await wallet.estimateGas(txRequest);
txRequest.gasLimit = gasEstimate.mul(120).div(100);
} catch {
txRequest.gasLimit = ethers.BigNumber.from(300_000);
}
const response = await wallet.sendTransaction(txRequest);
const receipt = await response.wait();
if (!receipt || receipt.status !== 1) {
throw new Error(`BSC ${tx.type} transaction reverted`);
}
if (tx.type === 'approve') {
approvalHashes.push(response.hash);
} else {
swapHash = response.hash;
}
}
return {
hash: swapHash,
explorerUrl: `https://bscscan.com/tx/${swapHash}`,
approvalHashes,
};
}

View File

@@ -0,0 +1,95 @@
import { webEnv } from '@/lib/env';
import { BSC_TOKEN_DECIMALS, BSC_PLATFORM_FEE_BPS } from '../constants';
export interface BscSwapQuoteRequest {
fromSymbol: string;
toSymbol: string;
amount: string; // human-readable
slippageBps: number;
}
export interface BscSwapQuoteResult {
amountIn: string;
amountInFormatted: string;
amountOut: string;
amountOutFormatted: string;
minimumAmountOutRaw: string;
minimumAmountOutFormatted: string;
from: string;
to: string;
}
export async function getBscSwapQuote(request: BscSwapQuoteRequest): Promise<BscSwapQuoteResult> {
const { fromSymbol, toSymbol, amount, slippageBps } = request;
const fromDecimals = BSC_TOKEN_DECIMALS[fromSymbol];
const toDecimals = BSC_TOKEN_DECIMALS[toSymbol];
if (fromDecimals === undefined || toDecimals === undefined) {
throw new Error(`Unsupported BSC token: ${fromSymbol} or ${toSymbol}`);
}
// Convert human-readable to raw
const amountRaw = toRawUnits(amount, fromDecimals);
// Deduct 0.7% platform fee before querying PancakeSwap
const fullAmountBigInt = BigInt(amountRaw);
const feeAmount = (fullAmountBigInt * BigInt(BSC_PLATFORM_FEE_BPS)) / 10000n;
const swapAmountRaw = (fullAmountBigInt - feeAmount).toString();
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const url = new URL(`${apiUrl}/api/bsc/swap/quote`);
url.searchParams.set('from', fromSymbol);
url.searchParams.set('to', toSymbol);
url.searchParams.set('amount', swapAmountRaw);
const response = await fetch(url.toString());
if (!response.ok) {
const body = await response.json().catch(() => ({ error: 'BSC quote request failed' }));
throw new Error(body.error || `BSC quote failed (${response.status})`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'BSC quote returned unsuccessful');
}
const amountOutRaw = data.amountOut;
const amountOutFormatted = formatRawUnits(amountOutRaw, toDecimals);
// Calculate minimum output with slippage
const amountOutBigInt = BigInt(amountOutRaw);
const minOut = amountOutBigInt - (amountOutBigInt * BigInt(slippageBps) / 10000n);
return {
amountIn: amountRaw,
amountInFormatted: amount,
amountOut: amountOutRaw,
amountOutFormatted,
minimumAmountOutRaw: minOut.toString(),
minimumAmountOutFormatted: formatRawUnits(minOut.toString(), toDecimals),
from: fromSymbol,
to: toSymbol,
};
}
function toRawUnits(amount: string, decimals: number): string {
const parts = amount.split('.');
const whole = parts[0] || '0';
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction)).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+$/, '')}`;
}

View File

@@ -0,0 +1,392 @@
import { Ether, Token } from '@uniswap/sdk-core';
import { FeeAmount } from '@uniswap/v3-sdk';
import { SWAP_PROXY_ADDRESS, UNIVERSAL_ROUTER_ADDRESS, URVersion, UniversalRouterVersion } from '@uniswap/universal-router-sdk';
export const ETHEREUM_CHAIN_ID = 1;
export const QUOTER_V2_ADDRESS = '0x61fFE014bA17989E743c5F6cB21bF9697530B21e';
export const V4_QUOTER_ADDRESS = '0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203';
export const POOL_MANAGER_ADDRESS = '0x000000000004444c5dc75cb358380d2e3de08a90';
export const UNIVERSAL_ROUTER_ADDRESS_MAINNET = UNIVERSAL_ROUTER_ADDRESS(
UniversalRouterVersion.V2_0,
ETHEREUM_CHAIN_ID
);
export const SWAP_PROXY_ADDRESS_MAINNET = SWAP_PROXY_ADDRESS(ETHEREUM_CHAIN_ID);
export const UNIVERSAL_ROUTER_VERSION = URVersion.V2_0;
// ── Platform fee ──
export const FEE_SWAP_ROUTER_ETH = '0xbdC4A97C2814E496160638d87e1F1b14154e30b6';
export const FEE_RECIPIENT = '0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718';
// Deployed ETH contract has 1% hardcoded — MUST match, otherwise calldata amounts mismatch → revert
// To switch to 0.7%, redeploy the ETH contract with FEE_BPS=70 and update the address above
export const PLATFORM_FEE_BPS = 100; // 1% — matches deployed FeeSwapRouter_ETH
export const ERC20_ABI = [
'function allowance(address owner, address spender) view returns (uint256)',
'function approve(address spender, uint256 value) returns (bool)',
'function balanceOf(address owner) view returns (uint256)',
] as const;
export const UNISWAP_V3_POOL_ABI = [
'function liquidity() view returns (uint128)',
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
'function ticks(int24 tick) view returns (uint128 liquidityGross, int128 liquidityNet, uint256 feeGrowthOutside0X128, uint256 feeGrowthOutside1X128, int56 tickCumulativeOutside, uint160 secondsPerLiquidityOutsideX128, uint32 secondsOutside, bool initialized)',
'function tickBitmap(int16 wordPosition) view returns (uint256)',
] as const;
export const QUOTER_V2_ABI = [
'function quoteExactInput(bytes path, uint256 amountIn) returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)',
] as const;
export type SwapTokenSymbol = 'ETH' | 'USDT' | 'USDC' | 'XAUT' | 'UNI' | 'PEPE' | 'stETH' | 'SHIB' | 'LINK' | 'POL' | 'WLFI' | 'AAVE';
export interface SwapTokenConfig {
symbol: SwapTokenSymbol;
address: string | 'native';
decimals: number;
isNative: boolean;
currency: ReturnType<typeof Ether.onChain> | Token;
wrappedToken: Token;
}
export interface PoolCandidate {
tokenA: Token;
tokenB: Token;
fee: FeeAmount;
}
export interface SwapQuoteRequest {
fromSymbol: SwapTokenSymbol;
toSymbol: SwapTokenSymbol;
amount: string;
slippageBps: number;
}
const WETH = new Token(
ETHEREUM_CHAIN_ID,
'0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
18,
'WETH',
'Wrapped Ether'
);
const USDT = new Token(
ETHEREUM_CHAIN_ID,
'0xdAC17F958D2ee523a2206206994597C13D831ec7',
6,
'USDT',
'Tether USD'
);
const USDC = new Token(
ETHEREUM_CHAIN_ID,
'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
6,
'USDC',
'USD Coin'
);
const XAUT = new Token(
ETHEREUM_CHAIN_ID,
'0x68749665FF8D2d112Fa859AA293F07A622782F38',
6,
'XAUT',
'Tether Gold'
);
const UNI = new Token(
ETHEREUM_CHAIN_ID,
'0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984',
18,
'UNI',
'Uniswap'
);
const PEPE = new Token(
ETHEREUM_CHAIN_ID,
'0x6982508145454Ce325dDbE47a25d4ec3d2311933',
18,
'PEPE',
'Pepe'
);
const STETH = new Token(ETHEREUM_CHAIN_ID, '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', 18, 'stETH', 'Lido Staked Ether');
const SHIB = new Token(ETHEREUM_CHAIN_ID, '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', 18, 'SHIB', 'Shiba Inu');
const LINK = new Token(ETHEREUM_CHAIN_ID, '0x514910771AF9Ca656af840dff83E8264EcF986CA', 18, 'LINK', 'Chainlink');
const POL_TOKEN = new Token(ETHEREUM_CHAIN_ID, '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6', 18, 'POL', 'Polygon');
const WLFI = new Token(ETHEREUM_CHAIN_ID, '0x66f85E3865D0cFDC009acf6280a8621f12e46CCf', 18, 'WLFI', 'World Liberty Financial');
const AAVE_TOKEN = new Token(ETHEREUM_CHAIN_ID, '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', 18, 'AAVE', 'Aave');
export const SWAP_TOKENS: Record<SwapTokenSymbol, SwapTokenConfig> = {
ETH: {
symbol: 'ETH',
address: 'native',
decimals: 18,
isNative: true,
currency: Ether.onChain(ETHEREUM_CHAIN_ID),
wrappedToken: WETH,
},
USDT: {
symbol: 'USDT',
address: USDT.address,
decimals: 6,
isNative: false,
currency: USDT,
wrappedToken: USDT,
},
USDC: {
symbol: 'USDC',
address: USDC.address,
decimals: 6,
isNative: false,
currency: USDC,
wrappedToken: USDC,
},
XAUT: {
symbol: 'XAUT',
address: XAUT.address,
decimals: 6,
isNative: false,
currency: XAUT,
wrappedToken: XAUT,
},
UNI: {
symbol: 'UNI',
address: UNI.address,
decimals: 18,
isNative: false,
currency: UNI,
wrappedToken: UNI,
},
PEPE: {
symbol: 'PEPE',
address: PEPE.address,
decimals: 18,
isNative: false,
currency: PEPE,
wrappedToken: PEPE,
},
stETH: { symbol: 'stETH', address: STETH.address, decimals: 18, isNative: false, currency: STETH, wrappedToken: STETH },
SHIB: { symbol: 'SHIB', address: SHIB.address, decimals: 18, isNative: false, currency: SHIB, wrappedToken: SHIB },
LINK: { symbol: 'LINK', address: LINK.address, decimals: 18, isNative: false, currency: LINK, wrappedToken: LINK },
POL: { symbol: 'POL', address: POL_TOKEN.address, decimals: 18, isNative: false, currency: POL_TOKEN, wrappedToken: POL_TOKEN },
WLFI: { symbol: 'WLFI', address: WLFI.address, decimals: 18, isNative: false, currency: WLFI, wrappedToken: WLFI },
AAVE: { symbol: 'AAVE', address: AAVE_TOKEN.address, decimals: 18, isNative: false, currency: AAVE_TOKEN, wrappedToken: AAVE_TOKEN },
};
export const SWAP_TOKEN_OPTIONS: SwapTokenSymbol[] = ['ETH', 'USDT', 'USDC', 'XAUT', 'UNI', 'PEPE', 'stETH', 'SHIB', 'LINK', 'POL', 'WLFI', 'AAVE'];
// ─── Multi-chain swap support ───
export type SwapChain = 'ETH' | 'SOL' | 'TRX' | 'BSC';
export const SOL_TOKEN_MINTS: Record<string, string> = {
SOL: 'So11111111111111111111111111111111111111112',
USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
PUMP: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn',
JUP: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN',
WIF: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm',
POPCAT: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr',
TRUMP: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN',
PYTH: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3',
JTO: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL',
W: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ',
BONK: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
ORCA: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE',
PENGU: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv',
RAY: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R',
};
export const SOL_TOKEN_DECIMALS: Record<string, number> = {
SOL: 9,
USDT: 6,
USDC: 6,
PUMP: 6,
JUP: 6,
WIF: 6,
POPCAT: 9,
TRUMP: 6,
PYTH: 6,
JTO: 9,
W: 6,
BONK: 5,
ORCA: 6,
PENGU: 6,
RAY: 6,
};
export const TRX_TOKEN_DECIMALS: Record<string, number> = {
TRX: 6,
USDT: 6,
};
// ── BSC platform fee (0.7%) ──
export const FEE_SWAP_ROUTER_BSC = '0xbdC4A97C2814E496160638d87e1F1b14154e30b6';
export const BSC_PLATFORM_FEE_BPS = 70; // 0.7% — matches deployed FeeSwapRouter_BSC
export const PANCAKE_SMART_ROUTER_BSC = '0x13f4EA83D0bd40E75C8222255bc855a974568Dd4';
export const WBNB_ADDRESS = '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c';
export const BSC_TOKEN_ADDRESSES: Record<string, string> = {
BNB: 'native',
USDT: '0x55d398326f99059fF775485246999027B3197955',
DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
};
export const BSC_TOKEN_DECIMALS: Record<string, number> = {
BNB: 18,
USDT: 18,
DOGE: 8,
};
export const SWAP_TOKEN_OPTIONS_BY_CHAIN: Record<SwapChain, string[]> = {
ETH: ['ETH', 'USDT', 'USDC', 'XAUT', 'UNI', 'PEPE', 'stETH', 'SHIB', 'LINK', 'POL', 'WLFI', 'AAVE'],
SOL: ['SOL', 'USDT', 'USDC', 'PUMP', 'JUP', 'WIF', 'POPCAT', 'TRUMP', 'PYTH', 'JTO', 'W', 'BONK', 'ORCA', 'PENGU', 'RAY'],
TRX: ['TRX', 'USDT'],
BSC: ['BNB', 'USDT', 'DOGE'],
};
export const CHAIN_DEFAULT_TOKENS: Record<SwapChain, { from: string; to: string }> = {
ETH: { from: 'ETH', to: 'USDT' },
SOL: { from: 'SOL', to: 'USDT' },
TRX: { from: 'TRX', to: 'USDT' },
BSC: { from: 'BNB', to: 'USDT' },
};
export function getSlippageBpsForChain(chain: SwapChain, fromSymbol: string, toSymbol: string): number {
if (chain === 'ETH') return getSlippageBps(fromSymbol as SwapTokenSymbol, toSymbol as SwapTokenSymbol);
// SOL
if (chain === 'SOL') {
const stablePair = (fromSymbol === 'USDT' && toSymbol === 'USDC') || (fromSymbol === 'USDC' && toSymbol === 'USDT');
if (stablePair) return 10; // 0.10%
return 50; // 0.50%
}
// BSC — PancakeSwap V2
if (chain === 'BSC') return 50; // 0.50%
// TRX — lower liquidity
return 100; // 1.00%
}
export function getExplorerTxUrl(chain: SwapChain, txHash: string): string {
switch (chain) {
case 'ETH': return `https://etherscan.io/tx/${txHash}`;
case 'SOL': return `https://solscan.io/tx/${txHash}`;
case 'TRX': return `https://tronscan.org/#/transaction/${txHash}`;
case 'BSC': return `https://bscscan.com/tx/${txHash}`;
}
}
export const V3_POOL_CANDIDATES: PoolCandidate[] = [
{ tokenA: WETH, tokenB: USDT, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: USDT, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: USDC, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: USDC, fee: FeeAmount.MEDIUM },
{ tokenA: USDT, tokenB: USDC, fee: FeeAmount.LOWEST },
{ tokenA: USDT, tokenB: USDC, fee: FeeAmount.LOW },
// XAUT pairs
{ tokenA: WETH, tokenB: XAUT, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: XAUT, fee: FeeAmount.HIGH },
{ tokenA: XAUT, tokenB: USDT, fee: FeeAmount.MEDIUM },
// UNI pairs
{ tokenA: WETH, tokenB: UNI, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: UNI, fee: FeeAmount.LOW },
{ tokenA: UNI, tokenB: USDT, fee: FeeAmount.MEDIUM },
// PEPE pairs
{ tokenA: WETH, tokenB: PEPE, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: PEPE, fee: FeeAmount.HIGH },
// stETH pairs
{ tokenA: WETH, tokenB: STETH, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: STETH, fee: FeeAmount.MEDIUM },
// SHIB pairs
{ tokenA: WETH, tokenB: SHIB, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: SHIB, fee: FeeAmount.HIGH },
// LINK pairs
{ tokenA: WETH, tokenB: LINK, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: LINK, fee: FeeAmount.MEDIUM },
// POL pairs
{ tokenA: WETH, tokenB: POL_TOKEN, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: POL_TOKEN, fee: FeeAmount.HIGH },
// WLFI pairs
{ tokenA: WETH, tokenB: WLFI, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: WLFI, fee: FeeAmount.HIGH },
// AAVE pairs
{ tokenA: WETH, tokenB: AAVE_TOKEN, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: AAVE_TOKEN, fee: FeeAmount.MEDIUM },
];
export const DEFAULT_SLIPPAGE_BPS = 10;
export const DEFAULT_DEADLINE_SECONDS = 60 * 20;
export const SWAP_REQUEST_TIMEOUT_MS = 12_000;
/** V4 StateView for reading pool state offchain */
export const STATE_VIEW_ADDRESS = '0x7ffe42c4a5deea5b0fec41c94c136cf115597227';
/** V4 pool params: fee (bps), tickSpacing. Hooks = zero for standard pools. */
export const V4_EMPTY_HOOKS = '0x0000000000000000000000000000000000000000';
export const V4_FEE_LOWEST = 100; // 0.01% - stablecoins
export const V4_FEE_LOW = 500; // 0.05%
export const V4_FEE_MEDIUM = 3000; // 0.30%
export const V4_TICK_SPACING_1 = 1;
export const V4_TICK_SPACING_10 = 10;
export const V4_TICK_SPACING_60 = 60;
/** V4 pool key candidates for ETH/USDT, ETH/USDC, USDT/USDC */
export const V4_POOL_KEY_CANDIDATES: Array<{
currencyA: Token | ReturnType<typeof Ether.onChain>;
currencyB: Token | ReturnType<typeof Ether.onChain>;
fee: number;
tickSpacing: number;
}> = [
{ currencyA: WETH, currencyB: USDT, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
{ currencyA: WETH, currencyB: USDT, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
{ currencyA: WETH, currencyB: USDC, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
{ currencyA: WETH, currencyB: USDC, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
{ currencyA: USDT, currencyB: USDC, fee: V4_FEE_LOWEST, tickSpacing: V4_TICK_SPACING_1 },
{ currencyA: USDT, currencyB: USDC, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
// XAUT pairs
{ currencyA: WETH, currencyB: XAUT, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
// UNI pairs
{ currencyA: WETH, currencyB: UNI, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
{ currencyA: WETH, currencyB: UNI, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
// PEPE pairs
{ currencyA: WETH, currencyB: PEPE, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
// stETH
{ currencyA: WETH, currencyB: STETH, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
// SHIB
{ currencyA: WETH, currencyB: SHIB, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
// LINK
{ currencyA: WETH, currencyB: LINK, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
{ currencyA: WETH, currencyB: LINK, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
// POL
{ currencyA: WETH, currencyB: POL_TOKEN, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
// WLFI
{ currencyA: WETH, currencyB: WLFI, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
// AAVE
{ currencyA: WETH, currencyB: AAVE_TOKEN, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
];
export function getSlippageBps(fromSymbol: SwapTokenSymbol, toSymbol: SwapTokenSymbol): number {
const stablePair =
(fromSymbol === 'USDT' && toSymbol === 'USDC') || (fromSymbol === 'USDC' && toSymbol === 'USDT');
if (stablePair) return 1; // 0.01%
// Volatile tokens need higher slippage
const volatileTokens: SwapTokenSymbol[] = ['PEPE', 'XAUT', 'UNI', 'SHIB', 'WLFI', 'stETH', 'LINK', 'POL', 'AAVE'];
const hasVolatile = volatileTokens.includes(fromSymbol) || volatileTokens.includes(toSymbol);
if (hasVolatile) return 50; // 0.50%
const hasStable =
fromSymbol === 'USDT' || fromSymbol === 'USDC' || toSymbol === 'USDT' || toSymbol === 'USDC';
if (hasStable) return 5; // 0.05%
return 10; // 0.10%
}
export function getSwapToken(symbol: SwapTokenSymbol): SwapTokenConfig {
return SWAP_TOKENS[symbol];
}
export function isErc20SwapToken(symbol: SwapTokenSymbol): boolean {
return !SWAP_TOKENS[symbol].isNative;
}

View File

@@ -0,0 +1,23 @@
import type { SwapChain } from './constants';
export function mapSwapError(chain: SwapChain, error: unknown): string {
const msg = error instanceof Error ? error.message : String(error);
// Jupiter / SOL
if (msg.includes('INSUFFICIENT_FUNDS') || msg.includes('InsufficientFunds')) return 'Insufficient SOL balance';
if (msg.includes('SlippageToleranceExceeded') || msg.includes('Slippage')) return 'Slippage exceeded — try increasing tolerance';
// SunSwap / TRX
if (msg.includes('BANDWIDTH_ERROR') || msg.includes('bandwidth')) return 'Insufficient bandwidth — need to freeze TRX';
if (msg.includes('ENERGY_ERROR') || msg.includes('energy')) return 'Insufficient energy — need TRX for gas';
if (msg.includes('CONTRACT_VALIDATE_ERROR')) return 'Transaction validation failed on TRON';
if (msg.includes('balance is not sufficient')) return 'Insufficient token balance';
// Generic
if (msg.includes('timeout') || msg.includes('timed out')) return 'Request timed out — please try again';
if (msg.includes('429') || msg.includes('rate')) return 'Rate limited — please wait and retry';
if (msg.includes('No route') || msg.includes('No Uniswap route')) return 'No swap route found for this pair';
if (msg.includes('not allowed') || msg.includes('403')) return 'API access restricted';
return msg;
}

View File

@@ -0,0 +1,131 @@
import { ethers } from 'ethers';
import { Percent } from '@uniswap/sdk-core';
import { SwapRouter, TokenTransferMode } from '@uniswap/universal-router-sdk';
import { createEthProvider } from '@/lib/eth-provider';
import {
DEFAULT_DEADLINE_SECONDS,
ETHEREUM_CHAIN_ID,
FEE_RECIPIENT,
FEE_SWAP_ROUTER_ETH,
PLATFORM_FEE_BPS,
SWAP_PROXY_ADDRESS_MAINNET,
UNIVERSAL_ROUTER_ADDRESS_MAINNET,
UNIVERSAL_ROUTER_VERSION,
getSwapToken,
type SwapQuoteRequest,
} from './constants';
import type { SwapQuoteResult } from './quote';
const provider = createEthProvider();
export interface ExecuteSwapParams {
privateKey: string;
request: SwapQuoteRequest;
quote: SwapQuoteResult;
maxFeeGwei?: string | null;
priorityFeeGwei?: string | null;
}
export interface ExecuteSwapResult {
hash: string;
explorerUrl: string;
submittedTo: string;
}
export async function executeSwap(params: ExecuteSwapParams): Promise<ExecuteSwapResult> {
const wallet = new ethers.Wallet(params.privateKey, provider);
const inputToken = getSwapToken(params.request.fromSymbol);
const slippageTolerance = new Percent(params.request.slippageBps, 10_000);
const deadline = Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS;
const routerOptions = {
recipient: wallet.address,
slippageTolerance,
deadlineOrPreviousBlockhash: deadline,
urVersion: UNIVERSAL_ROUTER_VERSION,
...(inputToken.isNative
? {}
: {
tokenTransferMode: TokenTransferMode.ApproveProxy,
chainId: ETHEREUM_CHAIN_ID,
}),
};
const methodParameters = SwapRouter.swapCallParameters(params.quote.trade, routerOptions);
const feeOverrides = await getFeeOverrides(params.maxFeeGwei, params.priorityFeeGwei);
let response: ethers.providers.TransactionResponse;
let submittedTo: string;
if (inputToken.isNative) {
// ── Native ETH → Token: route through FeeSwapRouter contract ──
// Quote was built for 99.3% of user's amount. We send the full original
// amount to the contract — it takes 0.7% fee and forwards 99.3% + calldata
// to the Universal Router.
const fullAmountRaw = ethers.utils.parseUnits(params.request.amount, 18);
const feeRouterIface = new ethers.utils.Interface([
'function swapNativeWithFee(bytes calldata routerCalldata) external payable',
]);
const data = feeRouterIface.encodeFunctionData('swapNativeWithFee', [
methodParameters.calldata,
]);
submittedTo = FEE_SWAP_ROUTER_ETH;
response = await wallet.sendTransaction({
to: FEE_SWAP_ROUTER_ETH,
data,
value: fullAmountRaw,
...feeOverrides,
});
} else {
// ── ERC20 → Token: send 0.7% fee separately, then swap 99.3% normally ──
const token = getSwapToken(params.request.fromSymbol);
const fullAmountRaw = ethers.utils.parseUnits(params.request.amount, token.decimals);
const feeAmount = fullAmountRaw.mul(PLATFORM_FEE_BPS).div(10000);
// Send 0.7% fee to fee wallet
const tokenContract = new ethers.Contract(
token.address,
['function transfer(address to, uint256 amount) returns (bool)'],
wallet,
);
const feeTx = await tokenContract.transfer(FEE_RECIPIENT, feeAmount, feeOverrides);
await feeTx.wait();
// Execute swap with 99% (calldata already built for adjusted amount)
submittedTo = SWAP_PROXY_ADDRESS_MAINNET;
response = await wallet.sendTransaction({
to: submittedTo,
data: methodParameters.calldata,
value: methodParameters.value,
...feeOverrides,
});
}
const receipt = await response.wait();
if (!receipt || receipt.status !== 1) {
throw new Error('Swap transaction reverted');
}
return {
hash: response.hash,
explorerUrl: `https://etherscan.io/tx/${response.hash}`,
submittedTo,
};
}
async function getFeeOverrides(maxFeeGwei?: string | null, priorityFeeGwei?: string | null) {
if (maxFeeGwei?.trim()) {
return {
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
};
}
const feeData = await provider.getFeeData();
return {
...(feeData.maxFeePerGas ? { maxFeePerGas: feeData.maxFeePerGas } : {}),
...(feeData.maxPriorityFeePerGas ? { maxPriorityFeePerGas: feeData.maxPriorityFeePerGas } : {}),
};
}

View File

@@ -0,0 +1,365 @@
import { ethers } from 'ethers';
import { CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core';
import { Pool, Route, TICK_SPACINGS, encodeRouteToPath } from '@uniswap/v3-sdk';
import { Trade as RouterTrade } from '@uniswap/router-sdk';
import { webEnv } from '@/lib/env';
import {
ETHEREUM_CHAIN_ID,
PLATFORM_FEE_BPS,
QUOTER_V2_ABI,
QUOTER_V2_ADDRESS,
SWAP_REQUEST_TIMEOUT_MS,
SWAP_TOKENS,
V3_POOL_CANDIDATES,
UNISWAP_V3_POOL_ABI,
getSlippageBps,
type PoolCandidate,
type SwapQuoteRequest,
type SwapTokenSymbol,
} from './constants';
/** TickDataProvider that fetches tick data from the Uniswap V3 pool contract */
function createContractTickDataProvider(
poolContract: ethers.Contract,
tickSpacing: number
): { getTick: (tick: number) => Promise<{ liquidityNet: string }>; nextInitializedTickWithinOneWord: (tick: number, lte: boolean, _tickSpacing: number) => Promise<[number, boolean]> } {
async function getTick(tick: number): Promise<{ liquidityNet: string }> {
const result = await poolContract.ticks(tick);
return { liquidityNet: result.liquidityNet.toString() };
}
async function nextInitializedTickWithinOneWord(tick: number, lte: boolean, _tickSpacing: number): Promise<[number, boolean]> {
const spacing = tickSpacing;
let compressed = Math.trunc(tick / spacing);
if (tick < 0 && tick % spacing !== 0) compressed--;
const wordPos = Math.floor(compressed / 256);
const bitPos = ((compressed % 256) + 256) % 256;
const word = (await poolContract.tickBitmap(wordPos)) as ethers.BigNumber;
const w = BigInt(word.toString());
let masked: bigint;
let nextCompressed: number;
if (lte) {
const mask = (1n << BigInt(bitPos + 1)) - 1n;
masked = w & mask;
const initialized = masked !== 0n;
if (initialized) {
let msbPos = 0;
for (let i = 255; i >= 0; i--) {
if ((masked >> BigInt(i)) & 1n) {
msbPos = i;
break;
}
}
nextCompressed = compressed - (bitPos - msbPos);
} else {
nextCompressed = compressed - bitPos;
}
return [nextCompressed * spacing, masked !== 0n];
} else {
const nextWordPos = Math.floor((compressed + 1) / 256);
const nextBitPos = (((compressed + 1) % 256) + 256) % 256;
const nextWord = (await poolContract.tickBitmap(nextWordPos)) as ethers.BigNumber;
const nw = BigInt(nextWord.toString());
const mask = ~((1n << BigInt(nextBitPos)) - 1n);
masked = nw & mask;
const initialized = masked !== 0n;
if (initialized) {
let lsbPos = 0;
for (let i = 0; i <= 255; i++) {
if ((masked >> BigInt(i)) & 1n) {
lsbPos = i;
break;
}
}
nextCompressed = compressed + 1 + (lsbPos - nextBitPos);
} else {
nextCompressed = compressed + 1 + (255 - nextBitPos);
}
return [nextCompressed * spacing, initialized];
}
}
return { getTick, nextInitializedTickWithinOneWord };
}
const ETH_RPC_CANDIDATES = [
...new Set([
webEnv.ethRpcUrl,
'https://ethereum-rpc.publicnode.com',
'https://rpc.ankr.com/eth',
'https://eth.llamarpc.com',
].filter(Boolean)),
];
const poolCache = new Map<string, Pool>();
const poolCacheTimestamps = new Map<string, number>();
const POOL_CACHE_TTL_MS = 30_000; // 30 seconds
async function getHealthyProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
let lastError: unknown = new Error('No Ethereum RPC available');
for (const rpcUrl of ETH_RPC_CANDIDATES) {
if (!rpcUrl?.trim()) continue;
const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, ETHEREUM_CHAIN_ID);
try {
await withTimeout(
provider.getBlockNumber(),
4_000,
'RPC health-check timed out'
);
return provider;
} catch (e) {
lastError = e;
}
}
throw lastError;
}
export interface SwapQuoteResult {
trade: RouterTrade<any, any, TradeType.EXACT_INPUT>;
amountInRaw: string;
amountInFormatted: string;
amountOutRaw: string;
amountOutFormatted: string;
minimumAmountOutRaw: string;
minimumAmountOutFormatted: string;
executionPrice: string;
priceImpact: string;
routeSymbols: SwapTokenSymbol[];
routeFees: number[];
}
/**
* Query the on-chain Quoter for each candidate pool and pick the best output.
* This replaces the fragile off-chain bestTradeExactIn simulation that fails
* with RATIO_CURRENT when tick data is stale.
*/
async function findBestRouteOnChain(
pools: Pool[],
inputToken: (typeof SWAP_TOKENS)[SwapTokenSymbol],
outputToken: (typeof SWAP_TOKENS)[SwapTokenSymbol],
amountInRaw: ethers.BigNumber,
quoter: ethers.Contract,
): Promise<{ bestPool: Pool; bestAmountOut: ethers.BigNumber; bestRoutePath: string }> {
const inputCurrency = inputToken.currency;
const outputCurrency = outputToken.currency;
// Filter pools that contain both input and output tokens
const relevantPools = pools.filter((pool) => {
const t0 = pool.token0.address.toLowerCase();
const t1 = pool.token1.address.toLowerCase();
const inAddr = (inputToken.wrappedToken?.address ?? inputToken.address).toLowerCase();
const outAddr = (outputToken.wrappedToken?.address ?? outputToken.address).toLowerCase();
return (t0 === inAddr || t1 === inAddr) && (t0 === outAddr || t1 === outAddr);
});
if (!relevantPools.length) {
throw new Error('No Uniswap pool available for this token pair');
}
let bestPool: Pool | null = null;
let bestAmountOut = ethers.BigNumber.from(0);
let bestRoutePath = '';
for (const pool of relevantPools) {
try {
const route = new Route([pool], inputCurrency, outputCurrency);
const routePath = encodeRouteToPath(route, false);
const [amountOut] = await withTimeout(
quoter.callStatic.quoteExactInput(routePath, amountInRaw),
SWAP_REQUEST_TIMEOUT_MS,
'Quote timed out',
);
if (amountOut.gt(bestAmountOut)) {
bestPool = pool;
bestAmountOut = amountOut;
bestRoutePath = routePath;
}
} catch {
// Pool doesn't have enough liquidity or RPC issue — skip it
continue;
}
}
if (!bestPool) {
throw new Error('No Uniswap route found for this token pair');
}
return { bestPool, bestAmountOut, bestRoutePath };
}
export async function getSwapQuote(request: SwapQuoteRequest): Promise<SwapQuoteResult> {
validateSwapRequest(request);
const provider = await getHealthyProvider();
const inputToken = SWAP_TOKENS[request.fromSymbol];
const outputToken = SWAP_TOKENS[request.toSymbol];
const originalAmountInRaw = ethers.utils.parseUnits(request.amount, inputToken.decimals);
// Apply 0.7% platform fee — swap only 99.3% of input (BigNumber math, no float)
const amountInRaw = originalAmountInRaw.mul(10000 - PLATFORM_FEE_BPS).div(10000);
const amountIn = CurrencyAmount.fromRawAmount(inputToken.currency, amountInRaw.toString());
const pools = await loadCandidatePools(provider);
const quoter = new ethers.Contract(QUOTER_V2_ADDRESS, QUOTER_V2_ABI, provider);
// Find best route via on-chain Quoter (avoids RATIO_CURRENT from off-chain simulation)
const { bestPool, bestAmountOut, bestRoutePath } = await findBestRouteOnChain(
pools, inputToken, outputToken, amountInRaw, quoter,
);
const route = new Route([bestPool], inputToken.currency, outputToken.currency);
const outputAmount = CurrencyAmount.fromRawAmount(outputToken.currency, bestAmountOut.toString());
const routerTrade = new RouterTrade({
v3Routes: [{
routev3: route,
inputAmount: amountIn,
outputAmount,
}],
tradeType: TradeType.EXACT_INPUT,
});
const slippageTolerance = new Percent(request.slippageBps, 10_000);
const minimumAmountOut = routerTrade.minimumAmountOut(slippageTolerance);
const outputDecimals = outputToken.decimals;
return {
trade: routerTrade,
amountInRaw: amountInRaw.toString(),
amountInFormatted: request.amount,
amountOutRaw: bestAmountOut.toString(),
amountOutFormatted: formatUnitsSafe(bestAmountOut.toString(), outputDecimals),
minimumAmountOutRaw: minimumAmountOut.quotient.toString(),
minimumAmountOutFormatted: formatUnitsSafe(minimumAmountOut.quotient.toString(), outputDecimals),
executionPrice: routerTrade.executionPrice.toSignificant(6),
priceImpact: routerTrade.priceImpact.toFixed(4),
routeSymbols: route.tokenPath.map((token) => tokenAddressToSymbol(token.address)),
routeFees: route.pools.map((pool) => pool.fee),
};
}
async function loadCandidatePools(provider: ethers.providers.StaticJsonRpcProvider): Promise<Pool[]> {
const settled = await Promise.allSettled(V3_POOL_CANDIDATES.map((c) => loadPool(c, provider)));
const pools = settled
.filter((result): result is PromiseFulfilledResult<Pool> => result.status === 'fulfilled')
.map((result) => result.value);
if (!pools.length) {
const errors = settled
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map((r) => r.reason?.message ?? String(r.reason));
const hasRpcIssue = errors.some(
(e) => e?.includes('Failed to fetch') || e?.includes('timeout') || e?.includes('ECONNREFUSED')
);
const hint = hasRpcIssue
? ' Check your connection or try a different RPC in NEXT_PUBLIC_ETH_RPC_URL.'
: '';
throw new Error(`No supported Uniswap pools are currently reachable.${hint}`);
}
return pools;
}
async function loadPool(candidate: PoolCandidate, provider: ethers.providers.StaticJsonRpcProvider): Promise<Pool> {
const cacheKey = `${candidate.tokenA.address}-${candidate.tokenB.address}-${candidate.fee}`;
const cachedPool = poolCache.get(cacheKey);
const cachedAt = poolCacheTimestamps.get(cacheKey) ?? 0;
if (cachedPool && Date.now() - cachedAt < POOL_CACHE_TTL_MS) {
return cachedPool;
}
const poolAddress = Pool.getAddress(candidate.tokenA, candidate.tokenB, candidate.fee);
const code = await withTimeout(
provider.getCode(poolAddress),
SWAP_REQUEST_TIMEOUT_MS,
'Pool discovery timed out'
);
if (!code || code === '0x') {
throw new Error(`Pool ${poolAddress} is unavailable`);
}
const poolContract = new ethers.Contract(poolAddress, UNISWAP_V3_POOL_ABI, provider);
const [liquidity, slot0] = await Promise.all([
withTimeout(poolContract.liquidity() as Promise<bigint>, SWAP_REQUEST_TIMEOUT_MS, 'Pool liquidity request timed out'),
withTimeout(
poolContract.slot0() as Promise<{ sqrtPriceX96: bigint; tick: number }>,
SWAP_REQUEST_TIMEOUT_MS,
'Pool slot0 request timed out'
),
]);
const tickSpacing = TICK_SPACINGS[candidate.fee];
const tickDataProvider = createContractTickDataProvider(poolContract, tickSpacing);
const pool = new Pool(
candidate.tokenA,
candidate.tokenB,
candidate.fee,
slot0.sqrtPriceX96.toString(),
liquidity.toString(),
slot0.tick,
tickDataProvider
);
poolCache.set(cacheKey, pool);
poolCacheTimestamps.set(cacheKey, Date.now());
return pool;
}
function validateSwapRequest(request: SwapQuoteRequest): void {
if (request.fromSymbol === request.toSymbol) {
throw new Error('Select two different tokens');
}
if (!request.amount || Number(request.amount) <= 0) {
throw new Error('Enter a valid swap amount');
}
if (!Number.isFinite(request.slippageBps) || request.slippageBps <= 0) {
throw new Error('Enter a valid slippage value');
}
}
function tokenAddressToSymbol(address: string): SwapTokenSymbol {
const normalizedAddress = address.toLowerCase();
const matched = Object.values(SWAP_TOKENS).find((token) => {
if (token.address === 'native') {
return token.wrappedToken.address.toLowerCase() === normalizedAddress;
}
return token.address.toLowerCase() === normalizedAddress;
});
return matched?.symbol ?? 'ETH';
}
function formatUnitsSafe(rawValue: bigint | string, decimals: number): string {
const bigintValue = typeof rawValue === 'bigint' ? rawValue : BigInt(rawValue);
if (bigintValue === 0n) {
return '0';
}
const divisor = 10n ** BigInt(decimals);
const whole = bigintValue / divisor;
const fraction = bigintValue % divisor;
if (fraction === 0n) {
return whole.toString();
}
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}

View File

@@ -0,0 +1,65 @@
import { Connection, Keypair, VersionedTransaction } from '@solana/web3.js';
import { webEnv } from '@/lib/env';
export interface SolExecuteSwapParams {
privateKeyHex: string;
userPublicKey: string;
quoteResponse: Record<string, unknown>;
}
export interface SolSwapResult {
hash: string;
explorerUrl: string;
}
export async function executeSolSwap(params: SolExecuteSwapParams): Promise<SolSwapResult> {
const { privateKeyHex, userPublicKey, quoteResponse } = params;
// 1. Build swap transaction via backend proxy
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const buildResponse = await fetch(`${apiUrl}/api/sol/swap/build`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quoteResponse, userPublicKey }),
});
if (!buildResponse.ok) {
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build swap' }));
throw new Error(body.error || `Swap build failed (${buildResponse.status})`);
}
const { swapTransaction } = await buildResponse.json();
if (!swapTransaction) {
throw new Error('No swap transaction returned from Jupiter');
}
// 2. Deserialize the VersionedTransaction
const txBuffer = Buffer.from(swapTransaction, 'base64');
const transaction = VersionedTransaction.deserialize(txBuffer);
// 3. Sign with user's Keypair
const keypair = Keypair.fromSecretKey(Buffer.from(privateKeyHex, 'hex'));
transaction.sign([keypair]);
// 4. Send to Solana RPC
const connection = new Connection(webEnv.solRpcUrl, 'confirmed');
const rawTx = transaction.serialize();
const signature = await connection.sendRawTransaction(rawTx, {
skipPreflight: false,
maxRetries: 2,
});
// 5. Confirm transaction
const latestBlockhash = await connection.getLatestBlockhash();
await connection.confirmTransaction({
signature,
blockhash: latestBlockhash.blockhash,
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
}, 'confirmed');
return {
hash: signature,
explorerUrl: `https://solscan.io/tx/${signature}`,
};
}

View File

@@ -0,0 +1,99 @@
import { SOL_TOKEN_MINTS, SOL_TOKEN_DECIMALS } from '../constants';
import { webEnv } from '@/lib/env';
export interface SolSwapQuoteResult {
chain: 'SOL';
quoteResponse: Record<string, unknown>;
amountInRaw: string;
amountInFormatted: string;
amountOutRaw: string;
amountOutFormatted: string;
minimumAmountOutRaw: string;
minimumAmountOutFormatted: string;
priceImpact: string;
routeLabels: string[];
}
export interface SolSwapQuoteRequest {
fromSymbol: string;
toSymbol: string;
amount: string;
slippageBps: number;
}
export async function getSolSwapQuote(request: SolSwapQuoteRequest): Promise<SolSwapQuoteResult> {
const inputMint = SOL_TOKEN_MINTS[request.fromSymbol];
const outputMint = SOL_TOKEN_MINTS[request.toSymbol];
if (!inputMint || !outputMint) {
throw new Error(`Unknown SOL token: ${request.fromSymbol} or ${request.toSymbol}`);
}
const fromDecimals = SOL_TOKEN_DECIMALS[request.fromSymbol];
const toDecimals = SOL_TOKEN_DECIMALS[request.toSymbol];
// Convert human-readable amount to raw (lamports / smallest unit)
const amountRaw = parseUnitsToRaw(request.amount, fromDecimals);
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const url = new URL(`${apiUrl}/api/sol/swap/quote`);
url.searchParams.set('inputMint', inputMint);
url.searchParams.set('outputMint', outputMint);
url.searchParams.set('amount', amountRaw);
url.searchParams.set('slippageBps', String(request.slippageBps));
const response = await fetch(url.toString());
if (!response.ok) {
const body = await response.json().catch(() => ({ error: 'Jupiter API error' }));
throw new Error(body.error || `Jupiter quote failed (${response.status})`);
}
const data = await response.json();
// Jupiter response fields
const outAmount = String(data.outAmount ?? '0');
const otherAmountThreshold = String(data.otherAmountThreshold ?? '0');
const priceImpactPct = String(data.priceImpactPct ?? '0');
// Extract route labels
const routeLabels: string[] = [];
if (Array.isArray(data.routePlan)) {
for (const step of data.routePlan) {
if (step.swapInfo?.label) routeLabels.push(step.swapInfo.label);
}
}
return {
chain: 'SOL',
quoteResponse: data,
amountInRaw: amountRaw,
amountInFormatted: request.amount,
amountOutRaw: outAmount,
amountOutFormatted: formatRawUnits(outAmount, toDecimals),
minimumAmountOutRaw: otherAmountThreshold,
minimumAmountOutFormatted: formatRawUnits(otherAmountThreshold, toDecimals),
priceImpact: priceImpactPct,
routeLabels,
};
}
function parseUnitsToRaw(amount: string, decimals: number): string {
const parts = amount.split('.');
const whole = parts[0] || '0';
let 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+$/, '')}`;
}

View File

@@ -0,0 +1,91 @@
import { ethers, utils } from 'ethers';
import { webEnv } from '@/lib/env';
export interface TrxExecuteSwapParams {
privateKeyHex: string; // 32-byte secp256k1 key (hex, no 0x prefix)
from: string;
to: string;
amount: string; // raw amount in sun
amountOutMin: string; // raw minimum output
userAddress: string; // TRX base58 address
}
export interface TrxSwapResult {
hash: string;
explorerUrl: string;
approvalHashes: string[];
}
export async function executeTrxSwap(params: TrxExecuteSwapParams): Promise<TrxSwapResult> {
const { privateKeyHex, from, to, amount, amountOutMin, userAddress } = params;
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
// 1. Build transaction(s) via backend
const buildResponse = await fetch(`${apiUrl}/api/tron/swap/build`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to, amount, amountOutMin, userAddress }),
});
if (!buildResponse.ok) {
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build TRX swap' }));
throw new Error(body.error || `TRX swap build failed (${buildResponse.status})`);
}
const { transactions } = await buildResponse.json();
if (!transactions || !transactions.length) {
throw new Error('No transactions returned from TRX swap builder');
}
// 2. Sign and broadcast each transaction in order
const signingKey = new utils.SigningKey('0x' + privateKeyHex);
const approvalHashes: string[] = [];
let swapHash = '';
for (const tx of transactions) {
// Sign the txID (which is SHA256 of raw_data)
const txID: string = tx.txID;
const digest = ethers.utils.arrayify('0x' + txID);
const signature = signingKey.signDigest(digest);
const sigHex = ethers.utils.joinSignature(signature).slice(2); // remove 0x, 65 bytes hex
// Add signature to transaction
const signedTx = {
...tx,
signature: [sigHex],
};
// Broadcast
const broadcastResponse = await fetch(`${apiUrl}/api/tron/swap/broadcast`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signedTransaction: signedTx }),
});
const result = await broadcastResponse.json();
if (!result.result) {
const errorMsg = result.message || result.code || 'Broadcast failed';
throw new Error(`TRX broadcast error: ${errorMsg}`);
}
if (tx.type === 'approve') {
approvalHashes.push(txID);
// Wait a bit for approval to be confirmed before swapping
await delay(3000);
} else {
swapHash = txID;
}
}
return {
hash: swapHash,
explorerUrl: `https://tronscan.org/#/transaction/${swapHash}`,
approvalHashes,
};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,90 @@
import { TRX_TOKEN_DECIMALS } from '../constants';
import { webEnv } from '@/lib/env';
export interface TrxSwapQuoteResult {
chain: 'TRX';
amountInRaw: string;
amountInFormatted: string;
amountOutRaw: string;
amountOutFormatted: string;
minimumAmountOutRaw: string;
minimumAmountOutFormatted: string;
from: string;
to: string;
}
export interface TrxSwapQuoteRequest {
fromSymbol: string;
toSymbol: string;
amount: string;
slippageBps: number;
}
export async function getTrxSwapQuote(request: TrxSwapQuoteRequest): Promise<TrxSwapQuoteResult> {
const fromDecimals = TRX_TOKEN_DECIMALS[request.fromSymbol];
const toDecimals = TRX_TOKEN_DECIMALS[request.toSymbol];
if (fromDecimals === undefined || toDecimals === undefined) {
throw new Error(`Unknown TRX token: ${request.fromSymbol} or ${request.toSymbol}`);
}
// Convert human-readable amount to raw (sun / smallest unit)
const amountRaw = parseUnitsToRaw(request.amount, fromDecimals);
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const url = new URL(`${apiUrl}/api/tron/swap/quote`);
url.searchParams.set('from', request.fromSymbol);
url.searchParams.set('to', request.toSymbol);
url.searchParams.set('amount', amountRaw);
const response = await fetch(url.toString());
if (!response.ok) {
const body = await response.json().catch(() => ({ error: 'TRX quote failed' }));
throw new Error(body.error || `TRX quote failed (${response.status})`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'TRX quote returned error');
}
const amountOut = String(data.amountOut);
// Apply slippage to get minimum output
const amountOutBigInt = BigInt(amountOut);
const minOut = amountOutBigInt - (amountOutBigInt * BigInt(request.slippageBps) / 10000n);
return {
chain: 'TRX',
amountInRaw: amountRaw,
amountInFormatted: request.amount,
amountOutRaw: amountOut,
amountOutFormatted: formatRawUnits(amountOut, toDecimals),
minimumAmountOutRaw: minOut.toString(),
minimumAmountOutFormatted: formatRawUnits(minOut.toString(), toDecimals),
from: request.fromSymbol,
to: request.toSymbol,
};
}
function parseUnitsToRaw(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+$/, '')}`;
}