add project
This commit is contained in:
43
apps/api/src/app.ts
Normal file
43
apps/api/src/app.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { env } from './config/env';
|
||||
import { swaggerSpec } from './config/swagger';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import walletRoutes from './routes/wallet.routes';
|
||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||
import tronProxyRoutes from './routes/tron-proxy.routes';
|
||||
import solSwapProxyRoutes from './routes/sol-swap-proxy.routes';
|
||||
import tronSwapProxyRoutes from './routes/tron-swap-proxy.routes';
|
||||
import btcProxyRoutes from './routes/btc-proxy.routes';
|
||||
import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cors({ origin: env.frontendUrl, credentials: true }));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ success: true, data: { status: 'ok' } });
|
||||
});
|
||||
|
||||
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
app.get('/api/docs/swagger.json', (_req, res) => {
|
||||
res.json(swaggerSpec);
|
||||
});
|
||||
|
||||
app.use('/api/wallets', walletRoutes);
|
||||
app.use('/api/relay', relayProxyRoutes);
|
||||
app.use('/api/tron', tronProxyRoutes);
|
||||
app.use('/api/sol/swap', solSwapProxyRoutes);
|
||||
app.use('/api/tron/swap', tronSwapProxyRoutes);
|
||||
app.use('/api/btc', btcProxyRoutes);
|
||||
app.use('/api/bsc/swap', bscSwapProxyRoutes);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
export default app;
|
||||
32
apps/api/src/config/database.ts
Normal file
32
apps/api/src/config/database.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import knex, { Knex } from 'knex';
|
||||
import { env } from './env';
|
||||
|
||||
let _db: Knex | null = null;
|
||||
|
||||
function getDb(): Knex {
|
||||
if (!_db) {
|
||||
_db = knex({
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: env.db.host,
|
||||
port: env.db.port,
|
||||
user: env.db.user,
|
||||
password: env.db.password,
|
||||
database: env.db.name,
|
||||
},
|
||||
pool: { min: 2, max: 10 },
|
||||
});
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
||||
const callableDb = (() => undefined) as unknown as Knex;
|
||||
|
||||
export const db = new Proxy(callableDb, {
|
||||
apply(_target, _thisArg, args) {
|
||||
return (getDb() as any)(...args);
|
||||
},
|
||||
get(_target, prop) {
|
||||
return (getDb() as any)[prop];
|
||||
},
|
||||
}) as Knex;
|
||||
28
apps/api/src/config/env.ts
Normal file
28
apps/api/src/config/env.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
|
||||
|
||||
export const env = {
|
||||
db: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
name: process.env.DB_NAME || 'cryptowallet_v2',
|
||||
},
|
||||
jwt: {
|
||||
jwksUrl: process.env.JWT_JWKS_URL || '',
|
||||
publicKey: process.env.JWT_PUBLIC_KEY || '',
|
||||
algorithm: process.env.JWT_ALGORITHM || 'RS256',
|
||||
issuer: process.env.JWT_ISSUER || '',
|
||||
audience: process.env.JWT_AUDIENCE || '',
|
||||
},
|
||||
port: parseInt(process.env.API_PORT || '3001'),
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||
relayApiKey: process.env.RELAY_API_KEY || null,
|
||||
tronApiKey: process.env.TRON_API_KEY || null,
|
||||
jupiterApiKey: process.env.JUPITER_API_KEY || null,
|
||||
jupiterReferralAccount: process.env.JUPITER_REFERRAL_ACCOUNT || null,
|
||||
jupiterFeeBps: parseInt(process.env.JUPITER_FEE_BPS || '70'),
|
||||
};
|
||||
5
apps/api/src/config/swagger.ts
Normal file
5
apps/api/src/config/swagger.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const swaggerPath = path.resolve(__dirname, '../../swagger.json');
|
||||
export const swaggerSpec = JSON.parse(fs.readFileSync(swaggerPath, 'utf-8'));
|
||||
20
apps/api/src/controllers/wallet.controller.ts
Normal file
20
apps/api/src/controllers/wallet.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { WalletModel } from '../models/wallet.model';
|
||||
|
||||
export const WalletController = {
|
||||
async getWallets(req: Request, res: Response) {
|
||||
try {
|
||||
const wallets = await WalletModel.findByUserId(req.auth!.userId);
|
||||
res.json({
|
||||
success: true,
|
||||
data: wallets.map((w) => ({
|
||||
chain: w.chain,
|
||||
address: w.address,
|
||||
derivationPath: w.derivation_path,
|
||||
})),
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
},
|
||||
};
|
||||
23
apps/api/src/db/knexfile.ts
Normal file
23
apps/api/src/db/knexfile.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Knex } from 'knex';
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load .env from repo root when running migrations directly
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
|
||||
|
||||
const config: Knex.Config = {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'cryptowallet_v2',
|
||||
},
|
||||
migrations: {
|
||||
directory: path.resolve(__dirname, 'migrations'),
|
||||
extension: __filename.endsWith('.js') ? 'js' : 'ts',
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
28
apps/api/src/db/migrations/001_create_users.ts
Normal file
28
apps/api/src/db/migrations/001_create_users.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable('users', (t) => {
|
||||
t.string('id', 26).primary();
|
||||
t.string('email', 255).notNullable().unique();
|
||||
t.string('password_hash', 255).notNullable();
|
||||
t.string('last_name', 128).nullable();
|
||||
t.string('first_name', 128).nullable();
|
||||
t.string('middle_name', 128).nullable();
|
||||
t.date('birth_date').nullable();
|
||||
t.string('crypto_wallet', 255).nullable();
|
||||
t.string('phone', 16).nullable();
|
||||
t.string('bik', 9).nullable();
|
||||
t.string('account_number', 20).nullable();
|
||||
t.string('card_number', 19).nullable();
|
||||
t.string('inn', 12).nullable();
|
||||
t.boolean('kyc_verified').notNullable().defaultTo(false);
|
||||
t.timestamp('kyc_verified_at', { useTz: true }).nullable();
|
||||
t.boolean('is_deleted').notNullable().defaultTo(false);
|
||||
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists('users');
|
||||
}
|
||||
20
apps/api/src/db/migrations/002_create_wallets.ts
Normal file
20
apps/api/src/db/migrations/002_create_wallets.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable('wallets', (t) => {
|
||||
t.string('id', 26).primary();
|
||||
t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE');
|
||||
t.string('chain', 10).notNullable();
|
||||
t.string('address', 256).notNullable();
|
||||
t.string('derivation_path', 64).notNullable();
|
||||
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
t.unique(['user_id', 'chain']);
|
||||
});
|
||||
|
||||
await knex.schema.raw('CREATE INDEX idx_wallets_user_id ON wallets(user_id)');
|
||||
await knex.schema.raw('CREATE INDEX idx_wallets_address ON wallets(address)');
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists('wallets');
|
||||
}
|
||||
26
apps/api/src/db/migrations/003_create_sessions.ts
Normal file
26
apps/api/src/db/migrations/003_create_sessions.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable('sessions', (t) => {
|
||||
t.string('id', 26).primary();
|
||||
t.string('sid', 26).notNullable().unique();
|
||||
t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE');
|
||||
t.string('device_id', 26).nullable();
|
||||
t.string('user_agent', 500).nullable();
|
||||
t.string('first_ip', 64).nullable();
|
||||
t.string('last_ip', 64).nullable();
|
||||
t.timestamp('last_seen_at', { useTz: true }).nullable();
|
||||
t.timestamp('revoked_at', { useTz: true }).nullable();
|
||||
t.string('refresh_jti_hash', 255).nullable();
|
||||
t.timestamp('refresh_expires_at', { useTz: true }).nullable();
|
||||
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
await knex.schema.raw('CREATE INDEX idx_sessions_user_id ON sessions(user_id)');
|
||||
await knex.schema.raw('CREATE INDEX idx_sessions_sid ON sessions(sid)');
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists('sessions');
|
||||
}
|
||||
23
apps/api/src/index.ts
Normal file
23
apps/api/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import knex from 'knex';
|
||||
import knexConfig from './db/knexfile';
|
||||
import app from './app';
|
||||
import { env } from './config/env';
|
||||
|
||||
async function main() {
|
||||
const db = knex(knexConfig);
|
||||
|
||||
console.log('[API] Running migrations...');
|
||||
await db.migrate.latest();
|
||||
console.log('[API] Migrations complete.');
|
||||
|
||||
await db.destroy();
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`[API] Server running on port ${env.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[API] Failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
39
apps/api/src/middleware/auth.ts
Normal file
39
apps/api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { verifyAccessToken, AuthContext } from '../services/jwt.service';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: AuthContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractToken(req: Request): string | null {
|
||||
const cookie = req.cookies?.access_token;
|
||||
if (cookie) return cookie;
|
||||
|
||||
const auth = req.headers.authorization;
|
||||
if (auth) {
|
||||
const [scheme, token] = auth.split(' ');
|
||||
if (scheme?.toLowerCase() === 'bearer' && token) return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const token = extractToken(req);
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ success: false, error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
req.auth = await verifyAccessToken(token);
|
||||
next();
|
||||
} catch (err: any) {
|
||||
res.status(err.status || 401).json({ success: false, error: err.message || 'Invalid token' });
|
||||
}
|
||||
}
|
||||
6
apps/api/src/middleware/error-handler.ts
Normal file
6
apps/api/src/middleware/error-handler.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
|
||||
console.error('[ERROR]', err.message);
|
||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
||||
}
|
||||
17
apps/api/src/middleware/validate.ts
Normal file
17
apps/api/src/middleware/validate.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodSchema } from 'zod';
|
||||
|
||||
export function validate(schema: ZodSchema) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const result = schema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error.errors.map((e) => e.message).join(', '),
|
||||
});
|
||||
return;
|
||||
}
|
||||
req.body = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
66
apps/api/src/models/session.model.ts
Normal file
66
apps/api/src/models/session.model.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { db } from '../config/database';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
sid: string;
|
||||
user_id: string;
|
||||
device_id: string | null;
|
||||
user_agent: string | null;
|
||||
first_ip: string | null;
|
||||
last_ip: string | null;
|
||||
last_seen_at: Date | null;
|
||||
revoked_at: Date | null;
|
||||
refresh_jti_hash: string | null;
|
||||
refresh_expires_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export const SessionModel = {
|
||||
async findBySid(sid: string): Promise<SessionRow | undefined> {
|
||||
return db('sessions').where({ sid }).whereNull('revoked_at').first();
|
||||
},
|
||||
|
||||
async findByUserId(userId: string): Promise<SessionRow[]> {
|
||||
return db('sessions').where({ user_id: userId }).whereNull('revoked_at');
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
sid: string;
|
||||
user_id: string;
|
||||
device_id?: string;
|
||||
user_agent?: string;
|
||||
first_ip?: string;
|
||||
refresh_jti_hash?: string;
|
||||
refresh_expires_at?: Date;
|
||||
}): Promise<SessionRow> {
|
||||
const [session] = await db('sessions')
|
||||
.insert({
|
||||
id: generateUlid(),
|
||||
...data,
|
||||
last_ip: data.first_ip || null,
|
||||
})
|
||||
.returning('*');
|
||||
return session;
|
||||
},
|
||||
|
||||
async revoke(sid: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ sid })
|
||||
.update({ revoked_at: db.fn.now(), updated_at: db.fn.now() });
|
||||
},
|
||||
|
||||
async revokeAllForUser(userId: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ user_id: userId })
|
||||
.whereNull('revoked_at')
|
||||
.update({ revoked_at: db.fn.now(), updated_at: db.fn.now() });
|
||||
},
|
||||
|
||||
async updateLastSeen(sid: string, ip: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ sid })
|
||||
.update({ last_seen_at: db.fn.now(), last_ip: ip, updated_at: db.fn.now() });
|
||||
},
|
||||
};
|
||||
49
apps/api/src/models/user.model.ts
Normal file
49
apps/api/src/models/user.model.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { db } from '../config/database';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
last_name: string | null;
|
||||
first_name: string | null;
|
||||
middle_name: string | null;
|
||||
birth_date: string | null;
|
||||
crypto_wallet: string | null;
|
||||
phone: string | null;
|
||||
bik: string | null;
|
||||
account_number: string | null;
|
||||
card_number: string | null;
|
||||
inn: string | null;
|
||||
kyc_verified: boolean;
|
||||
kyc_verified_at: Date | null;
|
||||
is_deleted: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export const UserModel = {
|
||||
async findByEmail(email: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ email, is_deleted: false }).first();
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ id, is_deleted: false }).first();
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
email: string;
|
||||
password_hash: string;
|
||||
}): Promise<UserRow> {
|
||||
const [user] = await db('users').insert({ id: generateUlid(), ...data }).returning('*');
|
||||
return user;
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<Omit<UserRow, 'id' | 'created_at'>>): Promise<UserRow | undefined> {
|
||||
const [user] = await db('users')
|
||||
.where({ id })
|
||||
.update({ ...data, updated_at: db.fn.now() })
|
||||
.returning('*');
|
||||
return user;
|
||||
},
|
||||
};
|
||||
24
apps/api/src/models/wallet.model.ts
Normal file
24
apps/api/src/models/wallet.model.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { db } from '../config/database';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
|
||||
export interface WalletRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
chain: string;
|
||||
address: string;
|
||||
derivation_path: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export const WalletModel = {
|
||||
async findByUserId(userId: string): Promise<WalletRow[]> {
|
||||
return db('wallets').where({ user_id: userId });
|
||||
},
|
||||
|
||||
async createMany(
|
||||
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[]
|
||||
): Promise<WalletRow[]> {
|
||||
const withIds = wallets.map((w) => ({ id: generateUlid(), ...w }));
|
||||
return db('wallets').insert(withIds).returning('*');
|
||||
},
|
||||
};
|
||||
192
apps/api/src/routes/bsc-swap-proxy.routes.ts
Normal file
192
apps/api/src/routes/bsc-swap-proxy.routes.ts
Normal 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); });
|
||||
});
|
||||
}
|
||||
148
apps/api/src/routes/btc-proxy.routes.ts
Normal file
148
apps/api/src/routes/btc-proxy.routes.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
52
apps/api/src/routes/relay-proxy.routes.ts
Normal file
52
apps/api/src/routes/relay-proxy.routes.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
166
apps/api/src/routes/sol-swap-proxy.routes.ts
Normal file
166
apps/api/src/routes/sol-swap-proxy.routes.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
268
apps/api/src/routes/tron-proxy.routes.ts
Normal file
268
apps/api/src/routes/tron-proxy.routes.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
471
apps/api/src/routes/tron-swap-proxy.routes.ts
Normal file
471
apps/api/src/routes/tron-swap-proxy.routes.ts
Normal 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;
|
||||
}
|
||||
9
apps/api/src/routes/wallet.routes.ts
Normal file
9
apps/api/src/routes/wallet.routes.ts
Normal 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;
|
||||
92
apps/api/src/services/jwt.service.ts
Normal file
92
apps/api/src/services/jwt.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as jose from 'jose';
|
||||
import { env } from '../config/env';
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
sub: string;
|
||||
type: string;
|
||||
sid: string;
|
||||
iat: number;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
iss?: string;
|
||||
aud?: string;
|
||||
}
|
||||
|
||||
export interface AuthContext {
|
||||
userId: string;
|
||||
sid: string;
|
||||
token: AccessTokenPayload;
|
||||
}
|
||||
|
||||
let jwks: ReturnType<typeof jose.createRemoteJWKSet> | null = null;
|
||||
let localKey: Awaited<ReturnType<typeof jose.importSPKI>> | null = null;
|
||||
|
||||
function getJWKS(): ReturnType<typeof jose.createRemoteJWKSet> {
|
||||
if (!jwks && env.jwt.jwksUrl) {
|
||||
jwks = jose.createRemoteJWKSet(new URL(env.jwt.jwksUrl));
|
||||
}
|
||||
if (!jwks) {
|
||||
throw new Error('JWT_JWKS_URL is not configured');
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
async function getLocalKey(): Promise<Awaited<ReturnType<typeof jose.importSPKI>>> {
|
||||
if (!localKey && env.jwt.publicKey) {
|
||||
localKey = await jose.importSPKI(env.jwt.publicKey, env.jwt.algorithm);
|
||||
}
|
||||
if (!localKey) {
|
||||
throw new Error('No JWT public key available');
|
||||
}
|
||||
return localKey;
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string): Promise<AuthContext> {
|
||||
let payload: jose.JWTPayload;
|
||||
|
||||
try {
|
||||
const verifyOptions: jose.JWTVerifyOptions = {
|
||||
algorithms: [env.jwt.algorithm],
|
||||
clockTolerance: 10,
|
||||
};
|
||||
if (env.jwt.issuer) verifyOptions.issuer = env.jwt.issuer;
|
||||
if (env.jwt.audience) verifyOptions.audience = env.jwt.audience;
|
||||
|
||||
if (env.jwt.jwksUrl) {
|
||||
const result = await jose.jwtVerify(token, getJWKS(), verifyOptions);
|
||||
payload = result.payload;
|
||||
} else {
|
||||
const key = await getLocalKey();
|
||||
const result = await jose.jwtVerify(token, key, verifyOptions);
|
||||
payload = result.payload;
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ERR_JWT_EXPIRED') {
|
||||
throw Object.assign(new Error('Token expired'), { status: 401 });
|
||||
}
|
||||
throw Object.assign(new Error('Invalid token'), { status: 401 });
|
||||
}
|
||||
|
||||
if (payload.type !== 'access') {
|
||||
throw Object.assign(new Error('Invalid token type'), { status: 401 });
|
||||
}
|
||||
|
||||
if (!payload.sub || !payload.sid) {
|
||||
throw Object.assign(new Error('Missing token claims'), { status: 401 });
|
||||
}
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
sid: payload.sid as string,
|
||||
token: {
|
||||
sub: payload.sub,
|
||||
type: payload.type as string,
|
||||
sid: payload.sid as string,
|
||||
iat: payload.iat!,
|
||||
nbf: payload.nbf!,
|
||||
exp: payload.exp!,
|
||||
iss: payload.iss,
|
||||
aud: typeof payload.aud === 'string' ? payload.aud : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
5
apps/api/src/utils/ulid.ts
Normal file
5
apps/api/src/utils/ulid.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ulid } from 'ulidx';
|
||||
|
||||
export function generateUlid(): string {
|
||||
return ulid();
|
||||
}
|
||||
Reference in New Issue
Block a user