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