add project

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

View File

@@ -0,0 +1,192 @@
import { Request, Response, Router } from 'express';
import { ethers } from 'ethers';
const router = Router();
const BSC_RPC = 'https://bsc-dataseed.binance.org';
const BSC_CHAIN_ID = 56;
const BSC_TIMEOUT_MS = 15_000;
// PancakeSwap V2 Router
const PANCAKE_ROUTER = '0x10ED43C718714eb63d5aA57B78B54704E256024E';
const WBNB = '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c';
// Supported tokens
const TOKEN_MAP: Record<string, string> = {
BNB: WBNB,
USDT: '0x55d398326f99059fF775485246999027B3197955',
DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
};
const TOKEN_DECIMALS: Record<string, number> = {
BNB: 18,
USDT: 18,
DOGE: 8,
};
const ROUTER_ABI = [
'function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts)',
'function swapExactETHForTokensSupportingFeeOnTransferTokens(uint amountOutMin, address[] calldata path, address to, uint deadline) external payable',
'function swapExactTokensForETHSupportingFeeOnTransferTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external',
];
const ERC20_ABI = [
'function approve(address spender, uint256 amount) external returns (bool)',
'function allowance(address owner, address spender) external view returns (uint256)',
];
router.get('/quote', getSwapQuote);
router.post('/build', buildSwapTx);
export default router;
// ─── GET /quote ───
async function getSwapQuote(req: Request, res: Response) {
const from = String(req.query.from || '').toUpperCase();
const to = String(req.query.to || '').toUpperCase();
const amount = String(req.query.amount || '');
if (!TOKEN_MAP[from] || !TOKEN_MAP[to]) {
res.status(400).json({ success: false, error: 'Invalid from/to. Supported: BNB, USDT, DOGE' });
return;
}
if (from === to) {
res.status(400).json({ success: false, error: 'from and to must be different' });
return;
}
const amountBigInt = BigInt(amount || '0');
if (amountBigInt <= 0n) {
res.status(400).json({ success: false, error: 'amount must be positive' });
return;
}
try {
const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID);
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
const path = [TOKEN_MAP[from], TOKEN_MAP[to]];
const amounts: ethers.BigNumber[] = await withTimeout(
routerContract.getAmountsOut(amount, path),
BSC_TIMEOUT_MS,
'PancakeSwap quote timed out'
);
const amountOut = amounts[amounts.length - 1].toString();
res.json({
success: true,
amountIn: amountBigInt.toString(),
amountOut,
from,
to,
fromDecimals: TOKEN_DECIMALS[from],
toDecimals: TOKEN_DECIMALS[to],
});
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to get BSC swap quote';
res.status(502).json({ success: false, error: msg });
}
}
// ─── POST /build ───
async function buildSwapTx(req: Request, res: Response) {
const { from, to, amount, amountOutMin, userAddress } = req.body;
if (!from || !to || !amount || !amountOutMin || !userAddress) {
res.status(400).json({ success: false, error: 'Missing required fields: from, to, amount, amountOutMin, userAddress' });
return;
}
const fromUpper = String(from).toUpperCase();
const toUpper = String(to).toUpperCase();
if (!TOKEN_MAP[fromUpper] || !TOKEN_MAP[toUpper] || fromUpper === toUpper) {
res.status(400).json({ success: false, error: 'Invalid from/to pair' });
return;
}
if (!ethers.utils.isAddress(userAddress)) {
res.status(400).json({ success: false, error: 'Invalid BSC address' });
return;
}
try {
const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID);
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes
const path = [TOKEN_MAP[fromUpper], TOKEN_MAP[toUpper]];
const transactions: Array<{ type: string; to: string; data: string; value: string }> = [];
if (fromUpper === 'BNB') {
// BNB → Token: swapExactETHForTokensSupportingFeeOnTransferTokens
const data = routerContract.interface.encodeFunctionData(
'swapExactETHForTokensSupportingFeeOnTransferTokens',
[amountOutMin, path, userAddress, deadline]
);
transactions.push({
type: 'swap',
to: PANCAKE_ROUTER,
data,
value: amount, // BNB amount in wei
});
} else {
// Token → BNB: check allowance, build approve if needed, then swap
const tokenAddress = TOKEN_MAP[fromUpper];
const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
const currentAllowance: ethers.BigNumber = await withTimeout(
tokenContract.allowance(userAddress, PANCAKE_ROUTER),
BSC_TIMEOUT_MS,
'Allowance check timed out'
);
if (currentAllowance.lt(ethers.BigNumber.from(amount))) {
// Build approve tx
const approveData = tokenContract.interface.encodeFunctionData(
'approve',
[PANCAKE_ROUTER, ethers.constants.MaxUint256]
);
transactions.push({
type: 'approve',
to: tokenAddress,
data: approveData,
value: '0',
});
}
// Build swap tx
const swapData = routerContract.interface.encodeFunctionData(
'swapExactTokensForETHSupportingFeeOnTransferTokens',
[amount, amountOutMin, path, userAddress, deadline]
);
transactions.push({
type: 'swap',
to: PANCAKE_ROUTER,
data: swapData,
value: '0',
});
}
res.json({ success: true, transactions });
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to build BSC swap';
res.status(502).json({ success: false, error: msg });
}
}
// ─── Utils ───
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => { clearTimeout(timeoutId); resolve(value); })
.catch((error) => { clearTimeout(timeoutId); reject(error); });
});
}

View File

@@ -0,0 +1,148 @@
import { Request, Response, Router } from 'express';
const router = Router();
const BLOCKSTREAM_BASE = 'https://blockstream.info/api';
const BTC_TIMEOUT_MS = 10_000;
// Validate Bitcoin address format (mainnet only)
const BTC_ADDRESS_RE = /^(bc1[a-zA-HJ-NP-Z0-9]{25,62}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})$/;
router.get('/utxos/:address', getUtxos);
router.get('/fee-estimates', getFeeEstimates);
router.post('/broadcast', broadcastTx);
export default router;
/**
* GET /api/btc/utxos/:address
* Returns confirmed UTXOs for the given address.
*/
async function getUtxos(req: Request, res: Response) {
const address = String(req.params.address);
if (!BTC_ADDRESS_RE.test(address)) {
res.status(400).json({ success: false, error: 'Invalid Bitcoin address' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), BTC_TIMEOUT_MS);
try {
const response = await fetch(`${BLOCKSTREAM_BASE}/address/${address}/utxo`, {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) {
res.status(response.status).json({ success: false, error: 'Blockstream API error' });
return;
}
const utxos = await response.json();
// Filter to confirmed only
const confirmed = (utxos as Array<{ status: { confirmed: boolean }; txid: string; vout: number; value: number }>)
.filter((u) => u.status?.confirmed)
.map((u) => ({
txid: u.txid,
vout: u.vout,
value: u.value,
}));
res.json({ success: true, data: confirmed });
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'Blockstream request timeout' });
return;
}
res.status(502).json({ success: false, error: 'Failed to reach Blockstream' });
} finally {
clearTimeout(timeout);
}
}
/**
* GET /api/btc/fee-estimates
* Returns fee rate estimates in sat/vB for different confirmation targets.
*/
async function getFeeEstimates(_req: Request, res: Response) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), BTC_TIMEOUT_MS);
try {
const response = await fetch(`${BLOCKSTREAM_BASE}/fee-estimates`, {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) {
res.status(response.status).json({ success: false, error: 'Blockstream fee estimates error' });
return;
}
const data = await response.json();
// Return top 3 tiers: 1-block, 3-block, 6-block confirmation targets
const estimates = data as Record<string, number>;
res.json({
success: true,
data: {
fast: Math.ceil(estimates['1'] ?? estimates['2'] ?? 10),
normal: Math.ceil(estimates['3'] ?? estimates['6'] ?? 5),
slow: Math.ceil(estimates['6'] ?? estimates['12'] ?? 2),
},
});
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'Blockstream fee estimates timeout' });
return;
}
res.status(502).json({ success: false, error: 'Failed to reach Blockstream' });
} finally {
clearTimeout(timeout);
}
}
/**
* POST /api/btc/broadcast
* Broadcasts a raw transaction hex.
*/
async function broadcastTx(req: Request, res: Response) {
const { hex } = req.body;
if (!hex || typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex)) {
res.status(400).json({ success: false, error: 'Invalid transaction hex' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), BTC_TIMEOUT_MS);
try {
const response = await fetch(`${BLOCKSTREAM_BASE}/tx`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: hex,
signal: controller.signal,
});
const text = await response.text();
if (!response.ok) {
res.status(response.status).json({ success: false, error: text || 'Broadcast failed' });
return;
}
// Blockstream returns the txid as plain text
res.json({ success: true, data: { txid: text.trim() } });
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'Broadcast timeout' });
return;
}
res.status(502).json({ success: false, error: 'Failed to broadcast transaction' });
} finally {
clearTimeout(timeout);
}
}

View File

@@ -0,0 +1,52 @@
import { NextFunction, Request, Response, Router } from 'express';
import { env } from '../config/env';
const router = Router();
const RELAY_API_URL = 'https://api.relay.link';
const ALLOWED_PATHS = new Set(['/quote/v2', '/intents/status/v3']);
router.use(proxyRelayRequest);
export default router;
async function proxyRelayRequest(req: Request, res: Response, next: NextFunction) {
try {
const relayPath = req.path;
if (!relayPath.startsWith('/execute/') && !ALLOWED_PATHS.has(relayPath)) {
res.status(404).json({ success: false, error: 'Relay endpoint not allowed' });
return;
}
const relayUrl = new URL(`${RELAY_API_URL}${relayPath}`);
Object.entries(req.query).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => relayUrl.searchParams.append(key, String(item)));
return;
}
if (typeof value !== 'undefined') {
relayUrl.searchParams.set(key, String(value));
}
});
const response = await fetch(relayUrl.toString(), {
method: req.method,
headers: {
Accept: 'application/json',
...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}),
...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}),
},
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
});
const contentType = response.headers.get('content-type') ?? 'application/json';
const payload = await response.text();
res.status(response.status);
res.type(contentType);
res.send(payload);
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,166 @@
import { Request, Response, Router } from 'express';
import { env } from '../config/env';
const router = Router();
const JUPITER_BASE = 'https://api.jup.ag/swap/v1';
const JUPITER_TIMEOUT_MS = 15_000;
const ALLOWED_MINTS = new Set([
'So11111111111111111111111111111111111111112', // SOL
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', // USDT
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC
'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', // PUMP
'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', // JUP
'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', // WIF
'7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', // POPCAT
'6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', // TRUMP
'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', // PYTH
'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', // JTO
'85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', // W
'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', // BONK
'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', // ORCA
'2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', // PENGU
'4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', // RAY
]);
router.get('/quote', getQuote);
router.post('/build', buildSwap);
export default router;
/**
* GET /api/sol/swap/quote
* Proxies to Jupiter GET /v6/quote
*/
async function getQuote(req: Request, res: Response) {
const { inputMint, outputMint, amount, slippageBps } = req.query;
if (!inputMint || !outputMint || !amount || !slippageBps) {
res.status(400).json({ success: false, error: 'Missing required params: inputMint, outputMint, amount, slippageBps' });
return;
}
if (!ALLOWED_MINTS.has(String(inputMint)) || !ALLOWED_MINTS.has(String(outputMint))) {
res.status(400).json({ success: false, error: 'Token mint not in whitelist' });
return;
}
if (inputMint === outputMint) {
res.status(400).json({ success: false, error: 'inputMint and outputMint must be different' });
return;
}
const parsedAmount = parseInt(String(amount), 10);
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
res.status(400).json({ success: false, error: 'amount must be a positive integer' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
try {
const url = new URL(`${JUPITER_BASE}/quote`);
url.searchParams.set('inputMint', String(inputMint));
url.searchParams.set('outputMint', String(outputMint));
url.searchParams.set('amount', String(parsedAmount));
url.searchParams.set('slippageBps', String(slippageBps));
// Platform fee (0.7%) — Jupiter deducts this natively
if (env.jupiterFeeBps > 0) {
url.searchParams.set('platformFeeBps', String(env.jupiterFeeBps));
}
const headers: Record<string, string> = { Accept: 'application/json' };
if (env.jupiterApiKey) {
headers['x-api-key'] = env.jupiterApiKey;
}
const response = await fetch(url.toString(), { headers, signal: controller.signal });
if (!response.ok) {
const text = await response.text().catch(() => 'Unknown error');
res.status(response.status).json({ success: false, error: `Jupiter API error: ${text}` });
return;
}
const data = await response.json();
res.json(data);
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'Jupiter quote request timed out' });
return;
}
res.status(502).json({ success: false, error: 'Failed to reach Jupiter API' });
} finally {
clearTimeout(timeout);
}
}
/**
* POST /api/sol/swap/build
* Proxies to Jupiter POST /v6/swap — returns serialized transaction for client signing
*/
async function buildSwap(req: Request, res: Response) {
const { quoteResponse, userPublicKey } = req.body;
if (!quoteResponse || typeof quoteResponse !== 'object') {
res.status(400).json({ success: false, error: 'Missing quoteResponse object' });
return;
}
if (!userPublicKey || typeof userPublicKey !== 'string') {
res.status(400).json({ success: false, error: 'Missing userPublicKey string' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (env.jupiterApiKey) {
headers['x-api-key'] = env.jupiterApiKey;
}
const swapBody: Record<string, unknown> = {
quoteResponse,
userPublicKey,
wrapAndUnwrapSol: true,
dynamicComputeUnitLimit: true,
prioritizationFeeLamports: 'auto',
};
// Attach referral fee account for Jupiter to route platform fees
if (env.jupiterReferralAccount) {
swapBody.feeAccount = env.jupiterReferralAccount;
}
const response = await fetch(`${JUPITER_BASE}/swap`, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify(swapBody),
});
if (!response.ok) {
const text = await response.text().catch(() => 'Unknown error');
res.status(response.status).json({ success: false, error: `Jupiter swap build error: ${text}` });
return;
}
const data = await response.json();
res.json(data);
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'Jupiter swap build timed out' });
return;
}
res.status(502).json({ success: false, error: 'Failed to reach Jupiter API' });
} finally {
clearTimeout(timeout);
}
}

View File

@@ -0,0 +1,268 @@
import { Request, Response, Router } from 'express';
import { env } from '../config/env';
const router = Router();
const TRONGRID_BASE = 'https://api.trongrid.io';
const TRON_TIMEOUT_MS = 10_000;
const TRON_ADDRESS_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
const USDT_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
router.get('/account/:address', getAccount);
router.post('/createtransaction', createTransaction);
router.post('/triggersmartcontract', triggerSmartContract);
router.post('/broadcasttransaction', broadcastTransaction);
export default router;
/**
* Decode a TRON base58check address to its 20-byte hex (without 0x41 prefix).
*/
function tronAddressToHex(address: string): string {
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'); // 25 bytes = 1 prefix + 20 addr + 4 checksum
return hex.slice(2, 42); // skip 0x41 prefix, take 20 bytes
}
/**
* Call balanceOf(address) on a TRC20 contract via triggerconstantcontract.
*/
async function fetchTrc20Balance(
ownerAddress: string,
contractAddress: string,
signal: AbortSignal
): Promise<string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (env.tronApiKey) {
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
}
const addressHex = tronAddressToHex(ownerAddress);
const parameter = addressHex.padStart(64, '0');
const response = await fetch(`${TRONGRID_BASE}/wallet/triggerconstantcontract`, {
method: 'POST',
headers,
signal,
body: JSON.stringify({
owner_address: ownerAddress,
contract_address: contractAddress,
function_selector: 'balanceOf(address)',
parameter,
visible: true,
}),
});
if (!response.ok) return '0';
const body = (await response.json()) as {
constant_result?: string[];
};
const hex = body.constant_result?.[0];
if (!hex || /^0+$/.test(hex)) return '0';
return BigInt('0x' + hex).toString();
}
async function getAccount(req: Request, res: Response) {
const address = String(req.params.address);
if (!TRON_ADDRESS_RE.test(address)) {
res.status(400).json({ success: false, error: 'Invalid TRON address' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
try {
const headers: Record<string, string> = { Accept: 'application/json' };
if (env.tronApiKey) {
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
}
// Fetch account data and USDT balance in parallel
const [accountRes, usdtBalance] = await Promise.all([
fetch(`${TRONGRID_BASE}/v1/accounts/${address}`, {
headers,
signal: controller.signal,
}),
fetchTrc20Balance(address, USDT_CONTRACT, controller.signal),
]);
if (!accountRes.ok) {
res.status(accountRes.status).json({ success: false, error: 'TronGrid error' });
return;
}
const accountData = (await accountRes.json()) as {
data?: Array<{
balance?: number;
trc20?: Array<Record<string, string>>;
[key: string]: unknown;
}>;
};
// Ensure data array has at least one entry
if (!accountData.data || accountData.data.length === 0) {
accountData.data = [{ balance: 0, trc20: [] }];
}
const account = accountData.data[0];
// Inject USDT balance from contract call (always more reliable)
if (usdtBalance !== '0') {
if (!account.trc20) account.trc20 = [];
const existingIdx = account.trc20.findIndex((t) => t[USDT_CONTRACT] !== undefined);
if (existingIdx >= 0) {
account.trc20[existingIdx] = { [USDT_CONTRACT]: usdtBalance };
} else {
account.trc20.push({ [USDT_CONTRACT]: usdtBalance });
}
}
res.json(accountData);
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'TronGrid request timeout' });
return;
}
res.status(502).json({ success: false, error: 'Failed to reach TronGrid' });
} finally {
clearTimeout(timeout);
}
}
/**
* POST /api/tron/createtransaction
* Proxies to TronGrid /wallet/createtransaction — builds unsigned TRX transfer transaction
*/
async function createTransaction(req: Request, res: Response) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
try {
const { owner_address, to_address, amount } = req.body;
if (!owner_address || !to_address || amount === undefined) {
res.status(400).json({ success: false, error: 'Missing required fields: owner_address, to_address, amount' });
return;
}
if (!TRON_ADDRESS_RE.test(owner_address) || !TRON_ADDRESS_RE.test(to_address)) {
res.status(400).json({ success: false, error: 'Invalid TRON address format' });
return;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (env.tronApiKey) {
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
}
const response = await fetch(`${TRONGRID_BASE}/wallet/createtransaction`, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({ owner_address, to_address, amount, visible: true }),
});
const data = await response.json();
res.status(response.ok ? 200 : 502).json(data);
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'TronGrid createtransaction timed out' });
return;
}
res.status(502).json({ success: false, error: 'Failed to reach TronGrid' });
} finally {
clearTimeout(timeout);
}
}
/**
* POST /api/tron/triggersmartcontract
* Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction
*/
async function triggerSmartContract(req: Request, res: Response) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (env.tronApiKey) {
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
}
const response = await fetch(`${TRONGRID_BASE}/wallet/triggersmartcontract`, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify(req.body),
});
const data = await response.json();
res.status(response.ok ? 200 : 502).json(data);
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'TronGrid triggersmartcontract timed out' });
return;
}
res.status(502).json({ success: false, error: 'Failed to reach TronGrid' });
} finally {
clearTimeout(timeout);
}
}
/**
* POST /api/tron/broadcasttransaction
* Proxies to TronGrid /wallet/broadcasttransaction
*/
async function broadcastTransaction(req: Request, res: Response) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (env.tronApiKey) {
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
}
const response = await fetch(`${TRONGRID_BASE}/wallet/broadcasttransaction`, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify(req.body),
});
const data = await response.json();
res.status(response.ok ? 200 : 502).json(data);
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'TronGrid broadcast timed out' });
return;
}
res.status(502).json({ success: false, error: 'Failed to broadcast transaction' });
} finally {
clearTimeout(timeout);
}
}

View File

@@ -0,0 +1,471 @@
import { Request, Response, Router } from 'express';
import { env } from '../config/env';
const router = Router();
const TRONGRID_BASE = 'https://api.trongrid.io';
const TRON_TIMEOUT_MS = 15_000;
const TRON_ADDRESS_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
// Contracts
const SUNSWAP_SMART_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax';
const USDT_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
const WTRX_CONTRACT = 'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR';
// FeeSwapRouter_TRX — deployed contract, 0.7% fee
const FEE_SWAP_ROUTER_TRX = 'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E';
const FEE_BPS = 70n;
const BPS_DENOMINATOR = 10_000n;
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
// Token map
const TOKEN_MAP: Record<string, string> = {
TRX: WTRX_CONTRACT,
USDT: USDT_CONTRACT,
};
const TOKEN_DECIMALS: Record<string, number> = {
TRX: 6,
USDT: 6,
};
router.get('/quote', getSwapQuote);
router.post('/build', buildSwapTx);
router.post('/broadcast', broadcastTx);
export default router;
// ─── Helpers ───
function tronAddressToHex(address: string): string {
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 0x41, take 20 bytes
}
function encodeUint256(value: bigint): string {
return value.toString(16).padStart(64, '0');
}
function encodeAddress(tronAddress: string): string {
const hex = tronAddressToHex(tronAddress);
return hex.padStart(64, '0');
}
function tronHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (env.tronApiKey) {
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
}
return headers;
}
// Encode bytes calldata as ABI dynamic bytes parameter
function encodeDynamicBytes(hexData: string): string {
// Remove 0x prefix if present
const data = hexData.startsWith('0x') ? hexData.slice(2) : hexData;
const byteLength = data.length / 2;
const lengthEncoded = encodeUint256(BigInt(byteLength));
// Pad data to 32-byte boundary
const paddedData = data.padEnd(Math.ceil(data.length / 64) * 64, '0');
return lengthEncoded + paddedData;
}
// ─── GET /quote ───
async function getSwapQuote(req: Request, res: Response) {
const from = String(req.query.from || '').toUpperCase();
const to = String(req.query.to || '').toUpperCase();
const amount = String(req.query.amount || '');
if (!TOKEN_MAP[from] || !TOKEN_MAP[to]) {
res.status(400).json({ success: false, error: 'Invalid from/to. Use TRX or USDT' });
return;
}
if (from === to) {
res.status(400).json({ success: false, error: 'from and to must be different' });
return;
}
const amountBigInt = BigInt(amount || '0');
if (amountBigInt <= 0n) {
res.status(400).json({ success: false, error: 'amount must be positive' });
return;
}
// Deduct 0.7% fee — SunSwap will only receive 99.3%
const feeAmount = (amountBigInt * FEE_BPS) / BPS_DENOMINATOR;
const amountAfterFee = amountBigInt - feeAmount;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
try {
const fromToken = TOKEN_MAP[from];
const toToken = TOKEN_MAP[to];
// ABI: getAmountsOut(uint256 amountIn, address[] path)
const amountHex = encodeUint256(amountAfterFee);
const offsetHex = encodeUint256(64n);
const lengthHex = encodeUint256(2n);
const addr0Hex = encodeAddress(fromToken);
const addr1Hex = encodeAddress(toToken);
const parameter = amountHex + offsetHex + lengthHex + addr0Hex + addr1Hex;
const response = await fetch(`${TRONGRID_BASE}/wallet/triggerconstantcontract`, {
method: 'POST',
headers: tronHeaders(),
signal: controller.signal,
body: JSON.stringify({
owner_address: SUNSWAP_SMART_ROUTER,
contract_address: SUNSWAP_SMART_ROUTER,
function_selector: 'getAmountsOut(uint256,address[])',
parameter,
visible: true,
}),
});
if (!response.ok) {
res.status(response.status).json({ success: false, error: 'TronGrid error' });
return;
}
const body = (await response.json()) as {
constant_result?: string[];
result?: { result?: boolean; message?: string };
};
if (!body.constant_result?.[0]) {
const errorMsg = body.result?.message
? Buffer.from(body.result.message, 'hex').toString('utf8')
: 'No result from getAmountsOut';
res.status(502).json({ success: false, error: errorMsg });
return;
}
const resultHex = body.constant_result[0];
const amountOutHex = resultHex.slice(-64);
const amountOut = BigInt('0x' + amountOutHex).toString();
res.json({
success: true,
amountIn: amountBigInt.toString(),
amountOut,
fee: feeAmount.toString(),
from,
to,
fromDecimals: TOKEN_DECIMALS[from],
toDecimals: TOKEN_DECIMALS[to],
});
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'TronGrid quote request timed out' });
return;
}
res.status(502).json({ success: false, error: 'Failed to reach TronGrid' });
} finally {
clearTimeout(timeout);
}
}
// ─── POST /build ───
async function buildSwapTx(req: Request, res: Response) {
const { from, to, amount, amountOutMin, userAddress } = req.body;
if (!from || !to || !amount || !amountOutMin || !userAddress) {
res.status(400).json({ success: false, error: 'Missing required fields: from, to, amount, amountOutMin, userAddress' });
return;
}
const fromUpper = String(from).toUpperCase();
const toUpper = String(to).toUpperCase();
if (!TOKEN_MAP[fromUpper] || !TOKEN_MAP[toUpper] || fromUpper === toUpper) {
res.status(400).json({ success: false, error: 'Invalid from/to pair' });
return;
}
if (!TRON_ADDRESS_RE.test(userAddress)) {
res.status(400).json({ success: false, error: 'Invalid TRON address' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
try {
const transactions: Array<{ txID: string; raw_data: unknown; raw_data_hex: string; type: string }> = [];
const amountBigInt = BigInt(amount);
const minOutBigInt = BigInt(amountOutMin);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 minutes
// Calculate fee and swap amounts
const feeAmount = (amountBigInt * FEE_BPS) / BPS_DENOMINATOR;
const swapAmount = amountBigInt - feeAmount;
if (fromUpper === 'TRX') {
// ═══ TRX → USDT: through FeeSwapRouter.swapNativeWithFee(bytes routerCalldata) ═══
// Step 1: Build the SunSwap calldata for swapExactETHForTokens
// SunSwap will receive swapAmount (99.3%) of TRX from FeeSwapRouter
// SunSwap sends output tokens to `to` address — must be userAddress
const sunswapCalldata = buildSwapExactETHForTokensCalldata(
minOutBigInt,
[WTRX_CONTRACT, USDT_CONTRACT],
userAddress,
deadline,
);
// Step 2: Wrap in swapNativeWithFee(bytes routerCalldata)
// ABI: swapNativeWithFee(bytes) — single dynamic bytes param
const offsetToBytes = encodeUint256(32n); // offset to dynamic bytes
const feeRouterParam = offsetToBytes + encodeDynamicBytes(sunswapCalldata);
const swapTx = await buildTriggerSmartContract({
ownerAddress: userAddress,
contractAddress: FEE_SWAP_ROUTER_TRX,
functionSelector: 'swapNativeWithFee(bytes)',
parameter: feeRouterParam,
callValue: Number(amountBigInt), // full amount — contract takes 0.7%
feeLimit: 200_000_000, // 200 TRX
signal: controller.signal,
});
if (swapTx) {
transactions.push({ ...swapTx, type: 'swap' });
}
} else {
// ═══ USDT → TRX: through FeeSwapRouter.swapTokenWithFee(address, uint256, bytes) ═══
// Step 1: Approve USDT to FeeSwapRouter (not SunSwap!)
const allowance = await checkAllowance(userAddress, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, controller.signal);
if (allowance < amountBigInt) {
const approveTx = await buildTriggerSmartContract({
ownerAddress: userAddress,
contractAddress: USDT_CONTRACT,
functionSelector: 'approve(address,uint256)',
parameter: encodeAddress(FEE_SWAP_ROUTER_TRX) + encodeUint256(BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff')),
callValue: 0,
feeLimit: 100_000_000,
signal: controller.signal,
});
if (approveTx) {
transactions.push({ ...approveTx, type: 'approve' });
}
}
// Step 2: Build SunSwap calldata for swapExactTokensForETH
// FeeSwapRouter will approve swapAmount (99.3%) to SunSwap and forward this calldata
const sunswapCalldata = buildSwapExactTokensForETHCalldata(
swapAmount, // 99.3% — what SunSwap actually receives
minOutBigInt,
[USDT_CONTRACT, WTRX_CONTRACT],
userAddress, // output TRX goes to user
deadline,
);
// Step 3: Wrap in swapTokenWithFee(address tokenIn, uint256 amountIn, bytes routerCalldata)
const tokenInEncoded = encodeAddress(USDT_CONTRACT);
const amountInEncoded = encodeUint256(amountBigInt); // full amount — contract takes 0.7%
const offsetToBytes = encodeUint256(96n); // offset to dynamic bytes (3 * 32)
const feeRouterParam = tokenInEncoded + amountInEncoded + offsetToBytes + encodeDynamicBytes(sunswapCalldata);
const swapTx = await buildTriggerSmartContract({
ownerAddress: userAddress,
contractAddress: FEE_SWAP_ROUTER_TRX,
functionSelector: 'swapTokenWithFee(address,uint256,bytes)',
parameter: feeRouterParam,
callValue: 0,
feeLimit: 200_000_000,
signal: controller.signal,
});
if (swapTx) {
transactions.push({ ...swapTx, type: 'swap' });
}
}
if (!transactions.length) {
res.status(502).json({ success: false, error: 'Failed to build swap transactions' });
return;
}
res.json({ success: true, transactions });
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'Build request timed out' });
return;
}
const msg = error instanceof Error ? error.message : 'Failed to build swap';
res.status(502).json({ success: false, error: msg });
} finally {
clearTimeout(timeout);
}
}
// ─── POST /broadcast ───
async function broadcastTx(req: Request, res: Response) {
const { signedTransaction } = req.body;
if (!signedTransaction) {
res.status(400).json({ success: false, error: 'Missing signedTransaction' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
try {
const response = await fetch(`${TRONGRID_BASE}/wallet/broadcasttransaction`, {
method: 'POST',
headers: tronHeaders(),
signal: controller.signal,
body: JSON.stringify(signedTransaction),
});
const data = await response.json();
res.status(response.ok ? 200 : 502).json(data);
} catch (error) {
if (controller.signal.aborted) {
res.status(504).json({ success: false, error: 'Broadcast timed out' });
return;
}
res.status(502).json({ success: false, error: 'Failed to broadcast transaction' });
} finally {
clearTimeout(timeout);
}
}
// ─── SunSwap Calldata Builders ───
// Build raw calldata hex for swapExactETHForTokens(uint256,address[],address,uint256)
function buildSwapExactETHForTokensCalldata(
amountOutMin: bigint,
path: string[], // TRON base58 addresses
to: string, // TRON base58 address
deadline: bigint,
): string {
// Function selector: keccak256("swapExactETHForTokens(uint256,address[],address,uint256)") first 4 bytes
const selector = 'b6f9de95';
const amountOutMinEnc = encodeUint256(amountOutMin);
const offsetToPath = encodeUint256(128n); // 4 * 32 bytes offset
const toEnc = encodeAddress(to);
const deadlineEnc = encodeUint256(deadline);
const pathLenEnc = encodeUint256(BigInt(path.length));
const pathElements = path.map((addr) => encodeAddress(addr)).join('');
return selector + amountOutMinEnc + offsetToPath + toEnc + deadlineEnc + pathLenEnc + pathElements;
}
// Build raw calldata hex for swapExactTokensForETH(uint256,uint256,address[],address,uint256)
function buildSwapExactTokensForETHCalldata(
amountIn: bigint,
amountOutMin: bigint,
path: string[], // TRON base58 addresses
to: string, // TRON base58 address
deadline: bigint,
): string {
// Function selector: keccak256("swapExactTokensForETH(uint256,uint256,address[],address,uint256)") first 4 bytes
const selector = '18cbafe5';
const amountInEnc = encodeUint256(amountIn);
const amountOutMinEnc = encodeUint256(amountOutMin);
const offsetToPath = encodeUint256(160n); // 5 * 32 bytes offset
const toEnc = encodeAddress(to);
const deadlineEnc = encodeUint256(deadline);
const pathLenEnc = encodeUint256(BigInt(path.length));
const pathElements = path.map((addr) => encodeAddress(addr)).join('');
return selector + amountInEnc + amountOutMinEnc + offsetToPath + toEnc + deadlineEnc + pathLenEnc + pathElements;
}
// ─── Internal Helpers ───
async function checkAllowance(
owner: string,
tokenContract: string,
spender: string,
signal: AbortSignal
): Promise<bigint> {
const parameter = encodeAddress(owner) + encodeAddress(spender);
const response = await fetch(`${TRONGRID_BASE}/wallet/triggerconstantcontract`, {
method: 'POST',
headers: tronHeaders(),
signal,
body: JSON.stringify({
owner_address: owner,
contract_address: tokenContract,
function_selector: 'allowance(address,address)',
parameter,
visible: true,
}),
});
if (!response.ok) return 0n;
const body = (await response.json()) as { constant_result?: string[] };
const hex = body.constant_result?.[0];
if (!hex || /^0+$/.test(hex)) return 0n;
return BigInt('0x' + hex);
}
interface TriggerSmartContractParams {
ownerAddress: string;
contractAddress: string;
functionSelector: string;
parameter: string;
callValue: number;
feeLimit: number;
signal: AbortSignal;
}
async function buildTriggerSmartContract(
params: TriggerSmartContractParams
): Promise<{ txID: string; raw_data: unknown; raw_data_hex: string } | null> {
const response = await fetch(`${TRONGRID_BASE}/wallet/triggersmartcontract`, {
method: 'POST',
headers: tronHeaders(),
signal: params.signal,
body: JSON.stringify({
owner_address: params.ownerAddress,
contract_address: params.contractAddress,
function_selector: params.functionSelector,
parameter: params.parameter,
call_value: params.callValue,
fee_limit: params.feeLimit,
visible: true,
}),
});
if (!response.ok) return null;
const body = (await response.json()) as {
result?: { result?: boolean; message?: string };
transaction?: { txID: string; raw_data: unknown; raw_data_hex: string };
};
if (!body.result?.result || !body.transaction) {
const errorMsg = body.result?.message
? Buffer.from(body.result.message, 'hex').toString('utf8')
: 'Transaction build failed';
throw new Error(errorMsg);
}
return body.transaction;
}

View File

@@ -0,0 +1,9 @@
import { Router } from 'express';
import { WalletController } from '../controllers/wallet.controller';
import { authMiddleware } from '../middleware/auth';
const router = Router();
router.get('/', authMiddleware, WalletController.getWallets);
export default router;