add project
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# PostgreSQL
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_NAME=cryptowallet_v2
|
||||||
|
|
||||||
|
# JWT (external auth service)
|
||||||
|
JWT_JWKS_URL=
|
||||||
|
JWT_PUBLIC_KEY=
|
||||||
|
JWT_ALGORITHM=RS256
|
||||||
|
JWT_ISSUER=
|
||||||
|
JWT_AUDIENCE=
|
||||||
|
|
||||||
|
# Server
|
||||||
|
API_PORT=3001
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
RELAY_API_KEY=
|
||||||
|
|
||||||
|
# TRON
|
||||||
|
TRON_API_KEY=
|
||||||
|
|
||||||
|
# Jupiter (Solana DEX aggregator)
|
||||||
|
JUPITER_API_KEY=
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.turbo/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
vault/data/
|
||||||
|
vault/init-keys.json
|
||||||
28
apps/api/Dockerfile
Normal file
28
apps/api/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
RUN pnpm install --frozen-lockfile --prod=false
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||||
|
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules 2>/dev/null || true
|
||||||
|
COPY . .
|
||||||
|
RUN cd apps/api && pnpm build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||||
|
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||||
|
COPY --from=build /app/apps/api/package.json ./apps/api/
|
||||||
|
COPY --from=build /app/packages/shared ./packages/shared
|
||||||
|
|
||||||
|
WORKDIR /app/apps/api
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
39
apps/api/package.json
Normal file
39
apps/api/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@cryptowallet/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"migrate": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:latest --knexfile src/db/knexfile.ts",
|
||||||
|
"migrate:rollback": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:rollback --knexfile src/db/knexfile.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cryptowallet/shared": "workspace:*",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.0",
|
||||||
|
"ethers": "5.7.2",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"jose": "^6.2.2",
|
||||||
|
"knex": "^3.1.0",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"ulidx": "^2.4.1",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.7",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/express-serve-static-core": "^5.1.1",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"ts-node": "^10.9.0",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
101
apps/api/swagger.json
Normal file
101
apps/api/swagger.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"info": {
|
||||||
|
"title": "CryptoWallet API",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Multi-chain cryptocurrency wallet API with blockchain proxy services"
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{ "url": "/api", "description": "API" }
|
||||||
|
],
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"bearerAuth": {
|
||||||
|
"type": "http",
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "JWT"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"Error": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": { "type": "boolean", "example": false },
|
||||||
|
"error": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Wallet": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chain": { "type": "string", "enum": ["ETH", "BTC", "SOL", "TRX", "BSC"] },
|
||||||
|
"address": { "type": "string" },
|
||||||
|
"derivationPath": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HealthResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": { "type": "boolean", "example": true },
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": { "type": "string", "example": "ok" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/health": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Health check",
|
||||||
|
"tags": ["System"],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Service is healthy",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/HealthResponse" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/wallets": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Get user wallets",
|
||||||
|
"tags": ["Wallets"],
|
||||||
|
"security": [{ "bearerAuth": [] }],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "List of wallets",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": { "type": "boolean", "example": true },
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/components/schemas/Wallet" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/Error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/api/tsconfig.json
Normal file
19
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
41
apps/web/.gitignore
vendored
Normal file
41
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
36
apps/web/README.md
Normal file
36
apps/web/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
28
apps/web/next.config.ts
Normal file
28
apps/web/next.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
turbopack: {
|
||||||
|
// Keep Turbopack pinned to the monorepo root so dev HMR
|
||||||
|
// does not mis-detect `apps/web` as a standalone project.
|
||||||
|
root: path.resolve(__dirname, '../..'),
|
||||||
|
},
|
||||||
|
webpack(config) {
|
||||||
|
// Enable WebAssembly for tiny-secp256k1 (used by ecpair/bitcoinjs-lib)
|
||||||
|
config.experiments = {
|
||||||
|
...config.experiments,
|
||||||
|
asyncWebAssembly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent webpack from changing the output of WASM imports
|
||||||
|
config.module.rules.push({
|
||||||
|
test: /\.wasm$/,
|
||||||
|
type: 'webassembly/async',
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
38
apps/web/package.json
Normal file
38
apps/web/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --webpack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@scure/bip32": "^2.0.1",
|
||||||
|
"@scure/bip39": "^2.0.1",
|
||||||
|
"@solana/web3.js": "^1.98.4",
|
||||||
|
"@uniswap/router-sdk": "^2.7.1",
|
||||||
|
"@uniswap/sdk-core": "^7.12.1",
|
||||||
|
"@uniswap/universal-router-sdk": "^4.34.0",
|
||||||
|
"@uniswap/v3-sdk": "^3.29.1",
|
||||||
|
"@uniswap/v4-sdk": "^1.29.1",
|
||||||
|
"@yudiel/react-qr-scanner": "^2.5.1",
|
||||||
|
"bip32": "^5.0.1",
|
||||||
|
"bitcoinjs-lib": "^7.0.1",
|
||||||
|
"ecpair": "^3.0.1",
|
||||||
|
"ed25519-hd-key": "^1.3.0",
|
||||||
|
"ethers": "5.7.2",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"tiny-secp256k1": "^2.2.4",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1770
apps/web/pnpm-lock.yaml
generated
Normal file
1770
apps/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
apps/web/pnpm-workspace.yaml
Normal file
3
apps/web/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
1
apps/web/public/file.svg
Normal file
1
apps/web/public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
apps/web/public/globe.svg
Normal file
1
apps/web/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
apps/web/public/next.svg
Normal file
1
apps/web/public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
apps/web/public/vercel.svg
Normal file
1
apps/web/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
apps/web/public/window.svg
Normal file
1
apps/web/public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
327
apps/web/src/app/bridge/page.tsx
Normal file
327
apps/web/src/app/bridge/page.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
BRIDGE_CHAINS,
|
||||||
|
BRIDGE_CHAIN_OPTIONS,
|
||||||
|
getDestinationChainOptions,
|
||||||
|
getTokenOptions,
|
||||||
|
getDefaultToken,
|
||||||
|
type BridgeChainKey,
|
||||||
|
} from '@/lib/bridge/constants';
|
||||||
|
import { useBridge } from '@/hooks/useBridge';
|
||||||
|
import { useGasPrice } from '@/hooks/useGasPrice';
|
||||||
|
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
|
||||||
|
const GAS_MODE_LABELS: Record<GasMode, string> = {
|
||||||
|
slow: 'Slow',
|
||||||
|
normal: 'Normal',
|
||||||
|
fast: 'Fast',
|
||||||
|
custom: 'Custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
|
||||||
|
|
||||||
|
export default function BridgePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const { data: gasPriceData, loading: gasLoading } = useGasPrice();
|
||||||
|
const gas = useGasSettings(gasPriceData);
|
||||||
|
const {
|
||||||
|
status, quote, bridgeStatus, requestId, txHashes, error,
|
||||||
|
sourceChain, setSourceChain, sourceWallet,
|
||||||
|
fetchQuote, submitBridge, resetBridge,
|
||||||
|
} = useBridge();
|
||||||
|
|
||||||
|
const [sourceToken, setSourceToken] = useState(() => getDefaultToken('ETH'));
|
||||||
|
const [destChain, setDestChain] = useState<BridgeChainKey>('SOL');
|
||||||
|
const [destToken, setDestToken] = useState(() => getDefaultToken('SOL'));
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
const destChainOptions = useMemo(() => getDestinationChainOptions(sourceChain), [sourceChain]);
|
||||||
|
const sourceTokenOptions = useMemo(() => getTokenOptions(sourceChain), [sourceChain]);
|
||||||
|
const destTokenOptions = useMemo(() => getTokenOptions(destChain), [destChain]);
|
||||||
|
|
||||||
|
const handleSourceChainChange = (newChain: BridgeChainKey) => {
|
||||||
|
setSourceChain(newChain);
|
||||||
|
setSourceToken(getDefaultToken(newChain));
|
||||||
|
// If dest chain is same as new source, switch dest
|
||||||
|
const newDestOptions = getDestinationChainOptions(newChain);
|
||||||
|
if (!newDestOptions.includes(destChain)) {
|
||||||
|
setDestChain(newDestOptions[0]);
|
||||||
|
setDestToken(getDefaultToken(newDestOptions[0]));
|
||||||
|
}
|
||||||
|
handleReset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDestChainChange = (newChain: BridgeChainKey) => {
|
||||||
|
setDestChain(newChain);
|
||||||
|
setDestToken(getDefaultToken(newChain));
|
||||||
|
handleReset();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const canQuote =
|
||||||
|
Number(amount) > 0 &&
|
||||||
|
status !== 'quoting' &&
|
||||||
|
status !== 'executing' &&
|
||||||
|
status !== 'monitoring';
|
||||||
|
const canBridge = !!quote && confirmed && status !== 'executing' && status !== 'monitoring';
|
||||||
|
const isEvmSource = sourceChain === 'ETH' || sourceChain === 'BSC';
|
||||||
|
const showGasControls = sourceChain === 'ETH'; // BSC uses fixed gas price
|
||||||
|
|
||||||
|
const handleQuote = async () => {
|
||||||
|
setConfirmed(false);
|
||||||
|
await fetchQuote({ sourceChain, sourceToken, destChain, destToken, amount });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBridge = async () => {
|
||||||
|
await submitBridge(
|
||||||
|
{ sourceChain, sourceToken, destChain, destToken, amount },
|
||||||
|
isEvmSource ? gas.effectiveMaxFee : null,
|
||||||
|
isEvmSource ? gas.effectivePriorityFee : null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setConfirmed(false);
|
||||||
|
resetBridge();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tierGwei = (mode: GasMode): string => {
|
||||||
|
if (mode === 'custom') return '';
|
||||||
|
if (!gasPriceData) return '...';
|
||||||
|
const v = gasPriceData[mode].maxFeePerGas;
|
||||||
|
if (v >= 1) return v.toFixed(2);
|
||||||
|
const s = v.toFixed(4);
|
||||||
|
return s.replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceExplorerBase = BRIDGE_CHAINS[sourceChain].explorerTxBaseUrl;
|
||||||
|
const destExplorerBase = BRIDGE_CHAINS[destChain].explorerTxBaseUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 720, margin: '50px auto', padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||||
|
<h1>Bridge</h1>
|
||||||
|
<Link href="/dashboard" style={navButtonStyle}>
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ border: '1px solid #ccc', padding: 16 }}>
|
||||||
|
{/* Source Chain */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>Source Chain</label>
|
||||||
|
<select
|
||||||
|
value={sourceChain}
|
||||||
|
onChange={(e) => handleSourceChainChange(e.target.value as BridgeChainKey)}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
{BRIDGE_CHAIN_OPTIONS.map((key) => (
|
||||||
|
<option key={key} value={key}>{BRIDGE_CHAINS[key].label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source Token */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>Source Token</label>
|
||||||
|
<select
|
||||||
|
value={sourceToken}
|
||||||
|
onChange={(e) => { setSourceToken(e.target.value); handleReset(); }}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
{sourceTokenOptions.map((sym) => (
|
||||||
|
<option key={sym} value={sym}>{sym}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destination Chain */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>Destination Chain</label>
|
||||||
|
<select
|
||||||
|
value={destChain}
|
||||||
|
onChange={(e) => handleDestChainChange(e.target.value as BridgeChainKey)}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
{destChainOptions.map((key) => (
|
||||||
|
<option key={key} value={key}>{BRIDGE_CHAINS[key].label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destination Token */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>Destination Token</label>
|
||||||
|
<select
|
||||||
|
value={destToken}
|
||||||
|
onChange={(e) => { setDestToken(e.target.value); handleReset(); }}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
{destTokenOptions.map((sym) => (
|
||||||
|
<option key={sym} value={sym}>{sym}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>Amount</label>
|
||||||
|
<input
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => { setAmount(e.target.value); handleReset(); }}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gas Speed — only for ETH source */}
|
||||||
|
{showGasControls ? (
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>Gas Speed {gasLoading ? '(loading...)' : ''}</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{GAS_MODES.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => gas.setGasMode(mode)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 4px',
|
||||||
|
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: gas.gasMode === mode ? '#f0f0f0' : '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{GAS_MODE_LABELS[mode]}</div>
|
||||||
|
{mode !== 'custom' && (
|
||||||
|
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
|
||||||
|
{tierGwei(mode)} gwei
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{gas.gasMode === 'custom' && (
|
||||||
|
<input
|
||||||
|
value={gas.customGwei}
|
||||||
|
onChange={(e) => gas.setCustomGwei(e.target.value)}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Enter gwei"
|
||||||
|
style={{ ...inputStyle, marginTop: 6 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p style={{ marginTop: 4, fontSize: 13, color: '#666' }}>
|
||||||
|
Effective: {gas.displayGwei}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : sourceChain === 'BSC' ? (
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>Fee</label>
|
||||||
|
<p style={{ fontSize: 13, color: '#666' }}>Fixed: <strong>0.055 gwei</strong> (BSC)</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>Fee</label>
|
||||||
|
<p style={{ fontSize: 13, color: '#666' }}>Auto (managed by Relay)</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={() => void handleQuote()} disabled={!canQuote} style={{ padding: '8px 16px' }}>
|
||||||
|
{status === 'quoting' ? 'Getting Quote...' : 'Get Quote'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quote Review */}
|
||||||
|
{quote && (
|
||||||
|
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
|
||||||
|
<h2 style={{ marginBottom: 12 }}>Review</h2>
|
||||||
|
<p>Expected output: <strong>{quote.outputAmountFormatted} {quote.outputSymbol}</strong></p>
|
||||||
|
<p>Minimum output: <strong>{quote.minimumAmountFormatted}</strong></p>
|
||||||
|
<p>Estimated fee: <strong>{quote.feeSummary}</strong></p>
|
||||||
|
<p>Estimated time: <strong>{quote.timeEstimateSeconds ? `${quote.timeEstimateSeconds}s` : 'Unavailable'}</strong></p>
|
||||||
|
{showGasControls && (
|
||||||
|
<p>Gas: <strong>{gas.displayGwei} ({GAS_MODE_LABELS[gas.gasMode]})</strong></p>
|
||||||
|
)}
|
||||||
|
{sourceChain === 'BSC' && (
|
||||||
|
<p>Gas: <strong>0.055 gwei</strong> (BSC fixed)</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'flex-start' }}>
|
||||||
|
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
|
||||||
|
<span>I confirm the bridge amount, fee and destination shown above.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button onClick={() => void handleBridge()} disabled={!canBridge} style={{ padding: '8px 16px', marginTop: 16 }}>
|
||||||
|
{status === 'executing' ? 'Executing...' : status === 'monitoring' ? 'Monitoring...' : 'Bridge'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
{(requestId || txHashes.length > 0 || bridgeStatus) && (
|
||||||
|
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
|
||||||
|
<h2 style={{ marginBottom: 12 }}>Status</h2>
|
||||||
|
{requestId && <p>Request ID: <strong>{requestId}</strong></p>}
|
||||||
|
{bridgeStatus && <p>Relay status: <strong>{bridgeStatus.status}</strong></p>}
|
||||||
|
{txHashes.map((hash) => (
|
||||||
|
<p key={hash}>
|
||||||
|
Origin tx:{' '}
|
||||||
|
<a href={`${sourceExplorerBase}${hash}`} target="_blank" rel="noreferrer">
|
||||||
|
{hash}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{(bridgeStatus?.txHashes ?? []).map((hash) => (
|
||||||
|
<p key={hash}>
|
||||||
|
Destination tx:{' '}
|
||||||
|
<a href={`${destExplorerBase}${hash}`} target="_blank" rel="noreferrer">
|
||||||
|
{hash}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || status === 'error') && (
|
||||||
|
<p style={{ color: 'red', marginTop: 16 }}>
|
||||||
|
{error ?? 'Bridge failed'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldGroupStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const navButtonStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
};
|
||||||
152
apps/web/src/app/dashboard/page.tsx
Normal file
152
apps/web/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useBalances } from '@/hooks/useBalances';
|
||||||
|
import type { ChainBalance } from '@/lib/balances/types';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { user, wallets } = useAuthStore();
|
||||||
|
const { portfolio, loading, refreshing, error, refresh } = useBalances();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 820, margin: '50px auto', padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
<span>{user?.email || 'Not authenticated'}</span>
|
||||||
|
<Link href="/send" style={navButtonStyle}>
|
||||||
|
Send
|
||||||
|
</Link>
|
||||||
|
<Link href="/receive" style={navButtonStyle}>
|
||||||
|
Receive
|
||||||
|
</Link>
|
||||||
|
<Link href="/swap" style={navButtonStyle}>
|
||||||
|
Swap
|
||||||
|
</Link>
|
||||||
|
<Link href="/bridge" style={navButtonStyle}>
|
||||||
|
Bridge
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings" style={navButtonStyle}>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
<button onClick={() => void refresh()} style={navButtonStyle} disabled={loading || refreshing}>
|
||||||
|
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20, marginBottom: 20 }}>
|
||||||
|
<p style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>
|
||||||
|
Total Portfolio USD
|
||||||
|
</p>
|
||||||
|
<h2 style={{ marginBottom: 8 }}>{formatUsd(portfolio?.totalUsd ?? null)}</h2>
|
||||||
|
<p style={{ color: '#666', fontSize: 14 }}>
|
||||||
|
{portfolio?.updatedAt
|
||||||
|
? `Updated ${new Date(portfolio.updatedAt).toLocaleTimeString()}`
|
||||||
|
: loading
|
||||||
|
? 'Loading balances...'
|
||||||
|
: 'Balances will appear after the first refresh.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: 'red', marginBottom: 12 }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{portfolio?.priceError && (
|
||||||
|
<p style={{ color: '#b45309', marginBottom: 12 }}>
|
||||||
|
USD pricing is partially unavailable: {portfolio.priceError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2>Your Wallets</h2>
|
||||||
|
{wallets.map((w) => {
|
||||||
|
const chainBalance = getChainBalance(w.chain, portfolio?.chains);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={w.chain} style={{ border: '1px solid #ccc', padding: 16, marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'center' }}>
|
||||||
|
<h3>{w.chain}</h3>
|
||||||
|
<span style={{ fontWeight: 600 }}>{formatUsd(chainBalance?.totalUsd ?? null)}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ wordBreak: 'break-all' }}>
|
||||||
|
<strong>Address:</strong> {w.address}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{chainBalance?.error && chainBalance.error !== '__transient__' && (
|
||||||
|
<p style={{ color: 'red', marginTop: 8 }}>
|
||||||
|
{chainBalance.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
{chainBalance?.tokens.length ? (
|
||||||
|
chainBalance.tokens.map((token) => (
|
||||||
|
<div
|
||||||
|
key={`${w.chain}-${token.symbol}`}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr 1fr',
|
||||||
|
gap: 12,
|
||||||
|
padding: '8px 0',
|
||||||
|
borderTop: '1px solid #eee',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{token.symbol}</span>
|
||||||
|
<span>{formatTokenAmount(token.balanceFormatted)}</span>
|
||||||
|
<span style={{ textAlign: 'right' }}>{formatUsd(token.valueUsd)}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p style={{ color: '#666', marginTop: 8 }}>
|
||||||
|
{loading ? 'Loading balances...' : 'No balances loaded yet.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChainBalance(chain: string, chains?: ChainBalance[]): ChainBalance | undefined {
|
||||||
|
return chains?.find((item) => item.chain === chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUsd(value: number | null): string {
|
||||||
|
if (typeof value !== 'number') {
|
||||||
|
return 'Unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenAmount(value: string): string {
|
||||||
|
const numericValue = Number(value);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericValue)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 6,
|
||||||
|
}).format(numericValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navButtonStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
};
|
||||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
42
apps/web/src/app/globals.css
Normal file
42
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--background);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/web/src/app/layout.tsx
Normal file
17
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Crypto Wallet",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
apps/web/src/app/page.module.css
Normal file
141
apps/web/src/app/page.module.css
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
.page {
|
||||||
|
--background: #fafafa;
|
||||||
|
--foreground: #fff;
|
||||||
|
|
||||||
|
--text-primary: #000;
|
||||||
|
--text-secondary: #666;
|
||||||
|
|
||||||
|
--button-primary-hover: #383838;
|
||||||
|
--button-secondary-hover: #f2f2f2;
|
||||||
|
--button-secondary-border: #ebebeb;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-geist-sans);
|
||||||
|
background-color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: var(--foreground);
|
||||||
|
padding: 120px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro h1 {
|
||||||
|
max-width: 320px;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 48px;
|
||||||
|
letter-spacing: -2.4px;
|
||||||
|
text-wrap: balance;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro p {
|
||||||
|
max-width: 440px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-wrap: balance;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctas {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctas a {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 128px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
width: fit-content;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.primary {
|
||||||
|
background: var(--text-primary);
|
||||||
|
color: var(--background);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.secondary {
|
||||||
|
border-color: var(--button-secondary-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enable hover only on non-touch devices */
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
a.primary:hover {
|
||||||
|
background: var(--button-primary-hover);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.secondary:hover {
|
||||||
|
background: var(--button-secondary-hover);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.main {
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 40px;
|
||||||
|
letter-spacing: -1.92px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.logo {
|
||||||
|
filter: invert();
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
--background: #000;
|
||||||
|
--foreground: #000;
|
||||||
|
|
||||||
|
--text-primary: #ededed;
|
||||||
|
--text-secondary: #999;
|
||||||
|
|
||||||
|
--button-primary-hover: #ccc;
|
||||||
|
--button-secondary-hover: #1a1a1a;
|
||||||
|
--button-secondary-border: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/web/src/app/page.tsx
Normal file
5
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
225
apps/web/src/app/receive/page.tsx
Normal file
225
apps/web/src/app/receive/page.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
import {
|
||||||
|
SEND_CHAIN_OPTIONS,
|
||||||
|
SEND_CHAINS,
|
||||||
|
getTokenOptions,
|
||||||
|
getDefaultToken,
|
||||||
|
type SendChain,
|
||||||
|
} from '@/lib/send/constants';
|
||||||
|
import { generateReceiveUri } from '@/lib/qr/generate';
|
||||||
|
|
||||||
|
export default function ReceivePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const wallets = useAuthStore((state) => state.wallets);
|
||||||
|
|
||||||
|
const [chain, setChain] = useState<SendChain>('ETH');
|
||||||
|
const [token, setToken] = useState<string>(getDefaultToken('ETH'));
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
// Reset token when chain changes
|
||||||
|
useEffect(() => {
|
||||||
|
setToken(getDefaultToken(chain));
|
||||||
|
setAmount('');
|
||||||
|
}, [chain]);
|
||||||
|
|
||||||
|
const wallet = useMemo(
|
||||||
|
() => wallets.find((w) => w.chain === SEND_CHAINS[chain].walletChain),
|
||||||
|
[wallets, chain],
|
||||||
|
);
|
||||||
|
|
||||||
|
const address = wallet?.address ?? '';
|
||||||
|
|
||||||
|
// Ensure token is valid for the current chain (guards against stale state during chain switch)
|
||||||
|
const effectiveToken = useMemo(() => {
|
||||||
|
const options = getTokenOptions(chain);
|
||||||
|
return options.includes(token) ? token : getDefaultToken(chain);
|
||||||
|
}, [chain, token]);
|
||||||
|
|
||||||
|
const qrUri = useMemo(() => {
|
||||||
|
if (!address) return '';
|
||||||
|
return generateReceiveUri({
|
||||||
|
chain,
|
||||||
|
token: effectiveToken,
|
||||||
|
address,
|
||||||
|
amount: amount.trim() || undefined,
|
||||||
|
});
|
||||||
|
}, [chain, effectiveToken, address, amount]);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!address) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(address);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Fallback
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = address;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const tokenOptions = getTokenOptions(chain);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<h1>Receive</h1>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Link href="/send" style={navButtonStyle}>Send</Link>
|
||||||
|
<Link href="/dashboard" style={navButtonStyle}>Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chain selector */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>Network</label>
|
||||||
|
<select
|
||||||
|
value={chain}
|
||||||
|
onChange={(e) => setChain(e.target.value as SendChain)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
{SEND_CHAIN_OPTIONS.map((c) => (
|
||||||
|
<option key={c} value={c}>{SEND_CHAINS[c].label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token selector */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>Token</label>
|
||||||
|
<select
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
{tokenOptions.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount (optional) */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<label style={labelStyle}>Amount (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (/^\d*\.?\d*$/.test(v)) setAmount(v);
|
||||||
|
}}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
{address && (
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: 16,
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
}}>
|
||||||
|
<QRCodeSVG value={qrUri} size={256} level="M" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Address display */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>Your {SEND_CHAINS[chain].label} Address</label>
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
background: '#f9f9f9',
|
||||||
|
}}>
|
||||||
|
{address || 'No wallet found for this chain'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copy button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={!address}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: address ? 'pointer' : 'not-allowed',
|
||||||
|
background: copied ? '#d4edda' : '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy Address'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* URI preview */}
|
||||||
|
{qrUri && (
|
||||||
|
<div style={{ marginTop: 16, fontSize: 11, color: '#888', wordBreak: 'break-all' }}>
|
||||||
|
<strong>QR URI:</strong> {qrUri}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 14,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const navButtonStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
};
|
||||||
561
apps/web/src/app/send/page.tsx
Normal file
561
apps/web/src/app/send/page.tsx
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Scanner } from '@yudiel/react-qr-scanner';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
import { useBalances } from '@/hooks/useBalances';
|
||||||
|
import { useGasPrice } from '@/hooks/useGasPrice';
|
||||||
|
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
|
||||||
|
import {
|
||||||
|
SEND_CHAIN_OPTIONS,
|
||||||
|
SEND_CHAINS,
|
||||||
|
getTokenOptions,
|
||||||
|
getDefaultToken,
|
||||||
|
type SendChain,
|
||||||
|
} from '@/lib/send/constants';
|
||||||
|
import { validateAddress } from '@/lib/send/validate';
|
||||||
|
import { parseQrUri } from '@/lib/qr/parse';
|
||||||
|
import { executeSend, type SendResult } from '@/lib/send/execute';
|
||||||
|
import type { ChainBalance } from '@/lib/balances/types';
|
||||||
|
|
||||||
|
const GAS_MODE_LABELS: Record<GasMode, string> = {
|
||||||
|
slow: 'Slow',
|
||||||
|
normal: 'Normal',
|
||||||
|
fast: 'Fast',
|
||||||
|
custom: 'Custom',
|
||||||
|
};
|
||||||
|
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
|
||||||
|
|
||||||
|
type SendStatus = 'idle' | 'review' | 'sending' | 'success' | 'error';
|
||||||
|
|
||||||
|
export default function SendPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const wallets = useAuthStore((state) => state.wallets);
|
||||||
|
const { portfolio } = useBalances();
|
||||||
|
const { data: gasPriceData } = useGasPrice();
|
||||||
|
const gas = useGasSettings(gasPriceData);
|
||||||
|
|
||||||
|
const [chain, setChain] = useState<SendChain>('ETH');
|
||||||
|
const [token, setToken] = useState<string>(getDefaultToken('ETH'));
|
||||||
|
const [recipient, setRecipient] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
|
const [status, setStatus] = useState<SendStatus>('idle');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<SendResult | null>(null);
|
||||||
|
const [scannerOpen, setScannerOpen] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
// Reset token on chain change
|
||||||
|
useEffect(() => {
|
||||||
|
setToken(getDefaultToken(chain));
|
||||||
|
setRecipient('');
|
||||||
|
setAmount('');
|
||||||
|
setConfirmed(false);
|
||||||
|
setStatus('idle');
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
}, [chain]);
|
||||||
|
|
||||||
|
const wallet = useMemo(
|
||||||
|
() => wallets.find((w) => w.chain === SEND_CHAINS[chain].walletChain),
|
||||||
|
[wallets, chain],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fromAddress = wallet?.address ?? '';
|
||||||
|
|
||||||
|
// Get available balance for the selected token
|
||||||
|
const availableBalance = useMemo(() => {
|
||||||
|
if (!portfolio?.chains) return null;
|
||||||
|
const chainBalance: ChainBalance | undefined = portfolio.chains.find(
|
||||||
|
(c) => c.chain === SEND_CHAINS[chain].walletChain,
|
||||||
|
);
|
||||||
|
if (!chainBalance) return null;
|
||||||
|
const tokenBalance = chainBalance.tokens.find((t) => t.symbol === token);
|
||||||
|
return tokenBalance?.balanceFormatted ?? null;
|
||||||
|
}, [portfolio, chain, token]);
|
||||||
|
|
||||||
|
// Validate address
|
||||||
|
const addressValidation = useMemo(() => {
|
||||||
|
if (!recipient.trim()) return null;
|
||||||
|
return validateAddress(chain, recipient);
|
||||||
|
}, [chain, recipient]);
|
||||||
|
|
||||||
|
// Handle QR scan
|
||||||
|
const handleScan = useCallback((results: Array<{ rawValue: string }>) => {
|
||||||
|
if (!results.length) return;
|
||||||
|
const raw = results[0].rawValue;
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
const parsed = parseQrUri(raw);
|
||||||
|
setScannerOpen(false);
|
||||||
|
|
||||||
|
if (parsed.chain) {
|
||||||
|
setChain(parsed.chain);
|
||||||
|
// Wait for chain useEffect, then set token/recipient/amount
|
||||||
|
setTimeout(() => {
|
||||||
|
if (parsed.token) setToken(parsed.token);
|
||||||
|
if (parsed.address) setRecipient(parsed.address);
|
||||||
|
if (parsed.amount) setAmount(parsed.amount);
|
||||||
|
}, 50);
|
||||||
|
} else if (parsed.address) {
|
||||||
|
setRecipient(parsed.address);
|
||||||
|
if (parsed.amount) setAmount(parsed.amount);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReview = () => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!recipient.trim()) {
|
||||||
|
setError('Recipient address is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateAddress(chain, recipient);
|
||||||
|
if (!validation.valid) {
|
||||||
|
setError(validation.error || 'Invalid address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!amount || Number(amount) <= 0) {
|
||||||
|
setError('Enter a valid amount');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('review');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!wallet) {
|
||||||
|
setError('Wallet not found for this chain');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('sending');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sendResult = await executeSend({
|
||||||
|
chain,
|
||||||
|
token,
|
||||||
|
toAddress: recipient.trim(),
|
||||||
|
amount,
|
||||||
|
privateKey: wallet.privateKey,
|
||||||
|
fromAddress,
|
||||||
|
maxFeeGwei: chain === 'ETH' ? gas.effectiveMaxFee : null,
|
||||||
|
priorityFeeGwei: chain === 'ETH' ? gas.effectivePriorityFee : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setResult(sendResult);
|
||||||
|
setStatus('success');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Transaction failed');
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setRecipient('');
|
||||||
|
setAmount('');
|
||||||
|
setConfirmed(false);
|
||||||
|
setStatus('idle');
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMax = () => {
|
||||||
|
if (availableBalance) {
|
||||||
|
setAmount(availableBalance);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const tokenOptions = getTokenOptions(chain);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<h1>Send</h1>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Link href="/receive" style={navButtonStyle}>Receive</Link>
|
||||||
|
<Link href="/dashboard" style={navButtonStyle}>Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Scanner Modal */}
|
||||||
|
{scannerOpen && (
|
||||||
|
<div style={overlayStyle}>
|
||||||
|
<div style={modalStyle}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Scan QR Code</h3>
|
||||||
|
<button onClick={() => setScannerOpen(false)} style={{ padding: '4px 8px', cursor: 'pointer' }}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||||
|
<Scanner
|
||||||
|
onScan={handleScan}
|
||||||
|
onError={(err) => {
|
||||||
|
console.error('QR scanner error:', err);
|
||||||
|
setScannerOpen(false);
|
||||||
|
}}
|
||||||
|
formats={['qr_code']}
|
||||||
|
styles={{ container: { width: '100%' } }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: '#888', marginTop: 8, textAlign: 'center' }}>
|
||||||
|
Point your camera at a QR code to auto-fill send details
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success state */}
|
||||||
|
{status === 'success' && result && (
|
||||||
|
<div style={{ border: '2px solid #28a745', borderRadius: 8, padding: 20, marginBottom: 20 }}>
|
||||||
|
<h3 style={{ color: '#28a745', marginTop: 0 }}>Transaction Sent!</h3>
|
||||||
|
<p style={{ wordBreak: 'break-all', fontSize: 13, fontFamily: 'monospace' }}>
|
||||||
|
<strong>TX Hash:</strong> {result.hash}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={result.explorerUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: '#007bff', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
View on Explorer
|
||||||
|
</a>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<button onClick={handleReset} style={primaryButtonStyle}>
|
||||||
|
Send Another
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form (hidden during success) */}
|
||||||
|
{status !== 'success' && (
|
||||||
|
<>
|
||||||
|
{/* Chain selector */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>Network</label>
|
||||||
|
<select
|
||||||
|
value={chain}
|
||||||
|
onChange={(e) => setChain(e.target.value as SendChain)}
|
||||||
|
style={selectStyle}
|
||||||
|
disabled={status === 'sending'}
|
||||||
|
>
|
||||||
|
{SEND_CHAIN_OPTIONS.map((c) => (
|
||||||
|
<option key={c} value={c}>{SEND_CHAINS[c].label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token selector */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>Token</label>
|
||||||
|
<select
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
disabled={status === 'sending'}
|
||||||
|
>
|
||||||
|
{tokenOptions.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{availableBalance !== null && (
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#666' }}>
|
||||||
|
Available: {availableBalance} {token}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recipient address */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>Recipient Address</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={`Enter ${SEND_CHAINS[chain].label} address`}
|
||||||
|
value={recipient}
|
||||||
|
onChange={(e) => setRecipient(e.target.value)}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
disabled={status === 'sending'}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setScannerOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
disabled={status === 'sending'}
|
||||||
|
>
|
||||||
|
Scan QR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{addressValidation && !addressValidation.valid && (
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'red' }}>{addressValidation.error}</p>
|
||||||
|
)}
|
||||||
|
{addressValidation?.valid && (
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#28a745' }}>Valid address</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>Amount</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (/^\d*\.?\d*$/.test(v)) setAmount(v);
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
disabled={status === 'sending'}
|
||||||
|
/>
|
||||||
|
{availableBalance !== null && (
|
||||||
|
<button
|
||||||
|
onClick={handleMax}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
disabled={status === 'sending'}
|
||||||
|
>
|
||||||
|
Max
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gas settings (ETH only) */}
|
||||||
|
{chain === 'ETH' && (
|
||||||
|
<div style={{ marginBottom: 16, padding: 12, border: '1px solid #eee', borderRadius: 4 }}>
|
||||||
|
<label style={labelStyle}>Gas Speed</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||||
|
{GAS_MODES.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => gas.setGasMode(mode)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 4px',
|
||||||
|
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: gas.gasMode === mode ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{GAS_MODE_LABELS[mode]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{gas.gasMode === 'custom' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="Max fee in gwei"
|
||||||
|
value={gas.customGwei}
|
||||||
|
onChange={(e) => gas.setCustomGwei(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#666' }}>
|
||||||
|
Estimated gas: {gas.displayGwei}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fee info for non-ETH chains */}
|
||||||
|
{chain !== 'ETH' && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<p style={{ fontSize: 12, color: '#666' }}>
|
||||||
|
{chain === 'SOL' && 'Fee: Auto (~0.000005 SOL)'}
|
||||||
|
{chain === 'TRX' && 'Fee: Auto (Energy/Bandwidth)'}
|
||||||
|
{chain === 'BTC' && 'Fee: Auto (market rate sat/vB)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ marginBottom: 16, fontSize: 12, color: '#999' }}>
|
||||||
|
Platform fee: 0.7% per transaction
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Review section */}
|
||||||
|
{status === 'review' && (
|
||||||
|
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16, marginBottom: 16 }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Review Transaction</h3>
|
||||||
|
<div style={reviewRowStyle}>
|
||||||
|
<span style={{ color: '#666' }}>From:</span>
|
||||||
|
<span style={{ wordBreak: 'break-all', fontSize: 12, fontFamily: 'monospace' }}>{fromAddress}</span>
|
||||||
|
</div>
|
||||||
|
<div style={reviewRowStyle}>
|
||||||
|
<span style={{ color: '#666' }}>To:</span>
|
||||||
|
<span style={{ wordBreak: 'break-all', fontSize: 12, fontFamily: 'monospace' }}>{recipient}</span>
|
||||||
|
</div>
|
||||||
|
<div style={reviewRowStyle}>
|
||||||
|
<span style={{ color: '#666' }}>Amount:</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{amount} {token}</span>
|
||||||
|
</div>
|
||||||
|
<div style={reviewRowStyle}>
|
||||||
|
<span style={{ color: '#666' }}>Network:</span>
|
||||||
|
<span>{SEND_CHAINS[chain].label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmed}
|
||||||
|
onChange={(e) => setConfirmed(e.target.checked)}
|
||||||
|
/>
|
||||||
|
I confirm this transaction is correct. This action is irreversible.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setStatus('idle'); setConfirmed(false); }}
|
||||||
|
style={{ ...navButtonStyle, flex: 1 }}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!confirmed}
|
||||||
|
style={{
|
||||||
|
...primaryButtonStyle,
|
||||||
|
flex: 1,
|
||||||
|
opacity: confirmed ? 1 : 0.5,
|
||||||
|
cursor: confirmed ? 'pointer' : 'not-allowed',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm & Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sending state */}
|
||||||
|
{status === 'sending' && (
|
||||||
|
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||||
|
<p>Sending transaction...</p>
|
||||||
|
<p style={{ fontSize: 12, color: '#666' }}>Please wait, do not close this page.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: 'red', marginBottom: 12, fontSize: 13 }}>{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
{(status === 'idle' || status === 'error') && (
|
||||||
|
<button onClick={handleReview} style={primaryButtonStyle}>
|
||||||
|
Review Transaction
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Styles ───
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 14,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const navButtonStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: '#fff',
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryButtonStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 16px',
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: '#333',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewRowStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
padding: '6px 0',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const overlayStyle: React.CSSProperties = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalStyle: React.CSSProperties = {
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
maxWidth: 440,
|
||||||
|
width: '90%',
|
||||||
|
};
|
||||||
45
apps/web/src/app/settings/page.tsx
Normal file
45
apps/web/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<Link href="/dashboard" style={navButtonStyle}>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Section */}
|
||||||
|
<div style={{ border: '1px solid #ccc', borderRadius: 4, padding: 16, marginBottom: 16 }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Account</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p style={{ margin: 0, fontWeight: 600 }}>Email</p>
|
||||||
|
<p style={{ margin: '4px 0 0', color: '#666', fontSize: 13 }}>{user?.email || 'Not authenticated'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styles ──
|
||||||
|
|
||||||
|
const navButtonStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: '#fff',
|
||||||
|
};
|
||||||
328
apps/web/src/app/swap/page.tsx
Normal file
328
apps/web/src/app/swap/page.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
type SwapChain,
|
||||||
|
SWAP_TOKEN_OPTIONS_BY_CHAIN,
|
||||||
|
CHAIN_DEFAULT_TOKENS,
|
||||||
|
getSlippageBpsForChain,
|
||||||
|
getExplorerTxUrl,
|
||||||
|
} from '@/lib/swap/constants';
|
||||||
|
import { useSwap, type MultiChainSwapRequest } from '@/hooks/useSwap';
|
||||||
|
import { useGasPrice } from '@/hooks/useGasPrice';
|
||||||
|
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
|
||||||
|
const GAS_MODE_LABELS: Record<GasMode, string> = {
|
||||||
|
slow: 'Slow',
|
||||||
|
normal: 'Normal',
|
||||||
|
fast: 'Fast',
|
||||||
|
custom: 'Custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
|
||||||
|
const CHAINS: SwapChain[] = ['ETH', 'SOL', 'TRX', 'BSC'];
|
||||||
|
|
||||||
|
export default function SwapPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const { data: gasPriceData, loading: gasLoading } = useGasPrice();
|
||||||
|
const gas = useGasSettings(gasPriceData);
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
quote,
|
||||||
|
error,
|
||||||
|
txHash,
|
||||||
|
approvalHashes,
|
||||||
|
liveEstimate,
|
||||||
|
chain,
|
||||||
|
setChain,
|
||||||
|
fetchQuote,
|
||||||
|
submitSwap,
|
||||||
|
resetSwap,
|
||||||
|
estimateOutput,
|
||||||
|
} = useSwap();
|
||||||
|
|
||||||
|
const tokenOptions = SWAP_TOKEN_OPTIONS_BY_CHAIN[chain];
|
||||||
|
const defaults = CHAIN_DEFAULT_TOKENS[chain];
|
||||||
|
const [fromSymbol, setFromSymbol] = useState(defaults.from);
|
||||||
|
const [toSymbol, setToSymbol] = useState(defaults.to);
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
|
|
||||||
|
const slippageBps = useMemo(() => getSlippageBpsForChain(chain, fromSymbol, toSymbol), [chain, fromSymbol, toSymbol]);
|
||||||
|
const slippagePercent = (slippageBps / 100).toFixed(2);
|
||||||
|
|
||||||
|
const request = useMemo<MultiChainSwapRequest>(
|
||||||
|
() => ({
|
||||||
|
chain,
|
||||||
|
fromSymbol,
|
||||||
|
toSymbol,
|
||||||
|
amount,
|
||||||
|
slippageBps,
|
||||||
|
}),
|
||||||
|
[chain, amount, fromSymbol, slippageBps, toSymbol]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
estimateOutput(request);
|
||||||
|
}, [estimateOutput, request]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canQuote = fromSymbol !== toSymbol && Number(amount) > 0 && request.slippageBps > 0;
|
||||||
|
const canSwap = !!quote && confirmed && status !== 'approving' && status !== 'swapping' && status !== 'quoting';
|
||||||
|
|
||||||
|
const handleChainChange = (newChain: SwapChain) => {
|
||||||
|
setChain(newChain);
|
||||||
|
const newDefaults = CHAIN_DEFAULT_TOKENS[newChain];
|
||||||
|
setFromSymbol(newDefaults.from);
|
||||||
|
setToSymbol(newDefaults.to);
|
||||||
|
setAmount('');
|
||||||
|
setConfirmed(false);
|
||||||
|
resetSwap();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuote = async () => {
|
||||||
|
setConfirmed(false);
|
||||||
|
await fetchQuote(request);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwap = async () => {
|
||||||
|
await submitSwap(request, gas.effectiveMaxFee, gas.effectivePriorityFee);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldReset = () => {
|
||||||
|
setConfirmed(false);
|
||||||
|
resetSwap();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tierGwei = (mode: GasMode): string => {
|
||||||
|
if (mode === 'custom') return '';
|
||||||
|
if (!gasPriceData) return '...';
|
||||||
|
const v = gasPriceData[mode].maxFeePerGas;
|
||||||
|
if (v >= 1) return v.toFixed(2);
|
||||||
|
const s = v.toFixed(4);
|
||||||
|
return s.replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 720, margin: '50px auto', padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||||
|
<h1>Swap</h1>
|
||||||
|
<Link href="/dashboard" style={navButtonStyle}>
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ border: '1px solid #ccc', padding: 16 }}>
|
||||||
|
{/* Chain selector */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
|
{CHAINS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => handleChainChange(c)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px 8px',
|
||||||
|
border: chain === c ? '2px solid #333' : '1px solid #ccc',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: chain === c ? '#f0f0f0' : '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: chain === c ? 700 : 400,
|
||||||
|
fontSize: 15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>From</label>
|
||||||
|
<select value={fromSymbol} onChange={(event) => { setFromSymbol(event.target.value); handleFieldReset(); }} style={inputStyle}>
|
||||||
|
{tokenOptions.map((symbol) => (
|
||||||
|
<option key={symbol} value={symbol}>{symbol}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>To</label>
|
||||||
|
<select value={toSymbol} onChange={(event) => { setToSymbol(event.target.value); handleFieldReset(); }} style={inputStyle}>
|
||||||
|
{tokenOptions.map((symbol) => (
|
||||||
|
<option key={symbol} value={symbol}>{symbol}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>Amount</label>
|
||||||
|
<input value={amount} onChange={(event) => { setAmount(event.target.value); handleFieldReset(); }} type="number" min="0" step="any" style={inputStyle} />
|
||||||
|
{liveEstimate && fromSymbol !== toSymbol && (
|
||||||
|
<p style={{ marginTop: 4, fontSize: 14, color: '#666' }}>
|
||||||
|
{liveEstimate.loading ? '...' : `~${liveEstimate.amountOut} ${toSymbol}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gas speed — only for ETH */}
|
||||||
|
{chain === 'ETH' && (
|
||||||
|
<div style={fieldGroupStyle}>
|
||||||
|
<label>Gas Speed {gasLoading ? '(loading...)' : ''}</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{GAS_MODES.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => gas.setGasMode(mode)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 4px',
|
||||||
|
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: gas.gasMode === mode ? '#f0f0f0' : '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{GAS_MODE_LABELS[mode]}</div>
|
||||||
|
{mode !== 'custom' && (
|
||||||
|
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
|
||||||
|
{tierGwei(mode)} gwei
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{gas.gasMode === 'custom' && (
|
||||||
|
<input
|
||||||
|
value={gas.customGwei}
|
||||||
|
onChange={(event) => gas.setCustomGwei(event.target.value)}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Enter gwei"
|
||||||
|
style={{ ...inputStyle, marginTop: 6 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p style={{ marginTop: 4, fontSize: 13, color: '#666' }}>
|
||||||
|
Effective: {gas.displayGwei}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fee info for non-ETH */}
|
||||||
|
{chain === 'SOL' && (
|
||||||
|
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
|
||||||
|
Fee: <strong>Auto</strong> (Jupiter Priority)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{chain === 'TRX' && (
|
||||||
|
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
|
||||||
|
Fee: <strong>Auto</strong> (Energy/Bandwidth)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{chain === 'BSC' && (
|
||||||
|
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
|
||||||
|
Fee: <strong>0.055 gwei</strong> (BSC fixed)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
|
||||||
|
Slippage: <strong>{slippagePercent}%</strong> (auto)
|
||||||
|
</p>
|
||||||
|
<p style={{ marginBottom: 12, fontSize: 13, color: '#999' }}>
|
||||||
|
Platform fee: 0.7% per swap
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{fromSymbol === toSymbol && (
|
||||||
|
<p style={{ color: 'red', marginBottom: 12 }}>From and To tokens must be different.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={() => void handleQuote()} disabled={!canQuote || status === 'quoting'} style={{ padding: '8px 16px' }}>
|
||||||
|
{status === 'quoting' ? 'Getting Quote...' : 'Get Quote'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quote && (
|
||||||
|
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
|
||||||
|
<h2 style={{ marginBottom: 12 }}>Review</h2>
|
||||||
|
<p>Expected output: <strong>{quote.amountOutFormatted} {toSymbol}</strong></p>
|
||||||
|
<p>Minimum output after slippage: <strong>{quote.minimumAmountOutFormatted} {toSymbol}</strong></p>
|
||||||
|
{'executionPrice' in quote && <p>Execution price: <strong>{quote.executionPrice}</strong></p>}
|
||||||
|
{'priceImpact' in quote && <p>Price impact: <strong>{quote.priceImpact}%</strong></p>}
|
||||||
|
{'routeSymbols' in quote && <p>Route: <strong>{quote.routeSymbols.join(' -> ')}</strong></p>}
|
||||||
|
{'routeFees' in quote && <p>Pool fees: <strong>{quote.routeFees.join(' / ')}</strong></p>}
|
||||||
|
{'routeLabels' in quote && (quote as any).routeLabels?.length > 0 && (
|
||||||
|
<p>Route: <strong>{(quote as any).routeLabels.join(' → ')}</strong></p>
|
||||||
|
)}
|
||||||
|
{chain === 'ETH' && <p>Gas: <strong>{gas.displayGwei} ({GAS_MODE_LABELS[gas.gasMode]})</strong></p>}
|
||||||
|
<p>Slippage: <strong>{slippagePercent}%</strong></p>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'flex-start' }}>
|
||||||
|
<input type="checkbox" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} />
|
||||||
|
<span>I confirm the amount, route and slippage shown above.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button onClick={() => void handleSwap()} disabled={!canSwap} style={{ padding: '8px 16px', marginTop: 16 }}>
|
||||||
|
{status === 'approving' ? 'Approving...' : status === 'swapping' ? 'Swapping...' : 'Swap'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(approvalHashes.length > 0 || txHash) && (
|
||||||
|
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
|
||||||
|
<h2 style={{ marginBottom: 12 }}>Transaction Status</h2>
|
||||||
|
{approvalHashes.map((hash) => (
|
||||||
|
<p key={hash}>
|
||||||
|
Approval tx:{' '}
|
||||||
|
<a href={getExplorerTxUrl(chain, hash)} target="_blank" rel="noreferrer">
|
||||||
|
{hash.slice(0, 16)}...
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{txHash && (
|
||||||
|
<p>
|
||||||
|
Swap tx:{' '}
|
||||||
|
<a href={getExplorerTxUrl(chain, txHash)} target="_blank" rel="noreferrer">
|
||||||
|
{txHash.slice(0, 16)}...
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || status === 'error') && (
|
||||||
|
<p style={{ color: 'red', marginTop: 16 }}>
|
||||||
|
{error ?? 'Swap failed'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldGroupStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const navButtonStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px 12px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
};
|
||||||
52
apps/web/src/hooks/useBalances.ts
Normal file
52
apps/web/src/hooks/useBalances.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
import { useBalanceStore } from '@/store/balance-store';
|
||||||
|
|
||||||
|
const BALANCE_REFRESH_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
export function useBalances() {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const wallets = useAuthStore((state) => state.wallets);
|
||||||
|
const portfolio = useBalanceStore((state) => state.portfolio);
|
||||||
|
const loading = useBalanceStore((state) => state.loading);
|
||||||
|
const refreshing = useBalanceStore((state) => state.refreshing);
|
||||||
|
const error = useBalanceStore((state) => state.error);
|
||||||
|
const fetchBalances = useBalanceStore((state) => state.fetchBalances);
|
||||||
|
const clearBalances = useBalanceStore((state) => state.clearBalances);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!user || !wallets.length) {
|
||||||
|
clearBalances();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchBalances(wallets);
|
||||||
|
}, [clearBalances, fetchBalances, user, wallets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !wallets.length) {
|
||||||
|
clearBalances();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchBalances(wallets);
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
void fetchBalances(wallets);
|
||||||
|
}, BALANCE_REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [clearBalances, fetchBalances, user, wallets]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
portfolio,
|
||||||
|
loading,
|
||||||
|
refreshing,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
191
apps/web/src/hooks/useBridge.ts
Normal file
191
apps/web/src/hooks/useBridge.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { executeBridge, type ExecuteBridgeResult } from '@/lib/bridge/execute';
|
||||||
|
import { getBridgeQuote, type BridgeQuoteResult } from '@/lib/bridge/quote';
|
||||||
|
import { getBridgeStatus, isBridgeTerminalStatus, type BridgeStatusResult } from '@/lib/bridge/status';
|
||||||
|
import {
|
||||||
|
BRIDGE_CHAINS,
|
||||||
|
getTokenConfig,
|
||||||
|
type BridgeChainKey,
|
||||||
|
} from '@/lib/bridge/constants';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
|
||||||
|
export type BridgeStatus = 'idle' | 'quoting' | 'quoted' | 'executing' | 'monitoring' | 'success' | 'error';
|
||||||
|
|
||||||
|
const BRIDGE_POLL_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
|
export interface BridgeRequestParams {
|
||||||
|
sourceChain: BridgeChainKey;
|
||||||
|
sourceToken: string;
|
||||||
|
destChain: BridgeChainKey;
|
||||||
|
destToken: string;
|
||||||
|
amount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBridge() {
|
||||||
|
const wallets = useAuthStore((state) => state.wallets);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<BridgeStatus>('idle');
|
||||||
|
const [quote, setQuote] = useState<BridgeQuoteResult | null>(null);
|
||||||
|
const [bridgeStatus, setBridgeStatus] = useState<BridgeStatusResult | null>(null);
|
||||||
|
const [requestId, setRequestId] = useState<string | null>(null);
|
||||||
|
const [txHashes, setTxHashes] = useState<string[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sourceChain, setSourceChain] = useState<BridgeChainKey>('ETH');
|
||||||
|
const pollTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pollTimeoutRef.current) {
|
||||||
|
window.clearTimeout(pollTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sourceWallet = useMemo(
|
||||||
|
() => wallets.find((w) => w.chain === BRIDGE_CHAINS[sourceChain].walletChain) ?? null,
|
||||||
|
[wallets, sourceChain],
|
||||||
|
);
|
||||||
|
|
||||||
|
function getWalletAddress(chainKey: BridgeChainKey): string | null {
|
||||||
|
const chain = BRIDGE_CHAINS[chainKey];
|
||||||
|
const wallet = wallets.find((w) => w.chain === chain.walletChain);
|
||||||
|
return wallet?.address ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchQuote = async (request: BridgeRequestParams) => {
|
||||||
|
const userAddress = getWalletAddress(request.sourceChain);
|
||||||
|
if (!userAddress) {
|
||||||
|
throw new Error(`${request.sourceChain} wallet is not available`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientAddress = getWalletAddress(request.destChain);
|
||||||
|
if (!recipientAddress) {
|
||||||
|
throw new Error(`${request.destChain} wallet is not available`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('quoting');
|
||||||
|
setError(null);
|
||||||
|
setBridgeStatus(null);
|
||||||
|
setRequestId(null);
|
||||||
|
setTxHashes([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextQuote = await getBridgeQuote({
|
||||||
|
...request,
|
||||||
|
userAddress,
|
||||||
|
recipientAddress,
|
||||||
|
});
|
||||||
|
setQuote(nextQuote);
|
||||||
|
setStatus('quoted');
|
||||||
|
return nextQuote;
|
||||||
|
} catch (nextError) {
|
||||||
|
setQuote(null);
|
||||||
|
setStatus('error');
|
||||||
|
setError(getErrorMessage(nextError));
|
||||||
|
throw nextError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitBridge = async (
|
||||||
|
request: BridgeRequestParams,
|
||||||
|
maxFeeGwei?: string | null,
|
||||||
|
priorityFeeGwei?: string | null,
|
||||||
|
) => {
|
||||||
|
if (!sourceWallet?.privateKey) {
|
||||||
|
throw new Error(`${request.sourceChain} private key is not available`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quote) {
|
||||||
|
throw new Error('Get a bridge quote before executing');
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('executing');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenConfig = getTokenConfig(request.sourceChain, request.sourceToken);
|
||||||
|
const execution = await executeBridge({
|
||||||
|
sourceChain: request.sourceChain,
|
||||||
|
sourceToken: request.sourceToken,
|
||||||
|
originalAmount: request.amount,
|
||||||
|
sourceTokenDecimals: tokenConfig.decimals,
|
||||||
|
sourceTokenAddress: tokenConfig.address,
|
||||||
|
privateKey: sourceWallet.privateKey,
|
||||||
|
quote: quote.quote,
|
||||||
|
maxFeeGwei,
|
||||||
|
priorityFeeGwei,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTxHashes(execution.txHashes);
|
||||||
|
if (!execution.requestId) {
|
||||||
|
throw new Error('Relay request ID was not returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestId(execution.requestId);
|
||||||
|
setStatus('monitoring');
|
||||||
|
await pollBridgeStatus(execution.requestId);
|
||||||
|
return execution;
|
||||||
|
} catch (nextError) {
|
||||||
|
setStatus('error');
|
||||||
|
setError(getErrorMessage(nextError));
|
||||||
|
throw nextError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetBridge = () => {
|
||||||
|
if (pollTimeoutRef.current) {
|
||||||
|
window.clearTimeout(pollTimeoutRef.current);
|
||||||
|
pollTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('idle');
|
||||||
|
setQuote(null);
|
||||||
|
setBridgeStatus(null);
|
||||||
|
setRequestId(null);
|
||||||
|
setTxHashes([]);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollBridgeStatus = async (nextRequestId: string): Promise<BridgeStatusResult> => {
|
||||||
|
const nextStatus = await getBridgeStatus(nextRequestId);
|
||||||
|
setBridgeStatus(nextStatus);
|
||||||
|
|
||||||
|
if (isBridgeTerminalStatus(nextStatus.status)) {
|
||||||
|
if (nextStatus.status === 'success') {
|
||||||
|
setStatus('success');
|
||||||
|
} else {
|
||||||
|
setStatus('error');
|
||||||
|
setError(nextStatus.details || `Bridge finished with status: ${nextStatus.status}`);
|
||||||
|
}
|
||||||
|
return nextStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
pollTimeoutRef.current = window.setTimeout(() => resolve(), BRIDGE_POLL_INTERVAL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
return pollBridgeStatus(nextRequestId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
quote,
|
||||||
|
bridgeStatus,
|
||||||
|
requestId,
|
||||||
|
txHashes,
|
||||||
|
error,
|
||||||
|
sourceChain,
|
||||||
|
setSourceChain,
|
||||||
|
sourceWallet,
|
||||||
|
fetchQuote,
|
||||||
|
submitBridge,
|
||||||
|
resetBridge,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return 'Bridge request failed';
|
||||||
|
}
|
||||||
36
apps/web/src/hooks/useGasPrice.ts
Normal file
36
apps/web/src/hooks/useGasPrice.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { fetchGasPrices, type GasPriceData } from '@/lib/gas-price';
|
||||||
|
|
||||||
|
const GAS_REFRESH_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
export function useGasPrice() {
|
||||||
|
const [data, setData] = useState<GasPriceData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const next = await fetchGasPrices();
|
||||||
|
if (!cancelled) setData(next);
|
||||||
|
} catch {
|
||||||
|
/* keep last known data */
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
const intervalId = setInterval(refresh, GAS_REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, loading };
|
||||||
|
}
|
||||||
64
apps/web/src/hooks/useGasSettings.ts
Normal file
64
apps/web/src/hooks/useGasSettings.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { GasPriceData } from '@/lib/gas-price';
|
||||||
|
|
||||||
|
export type GasMode = 'slow' | 'normal' | 'fast' | 'custom';
|
||||||
|
|
||||||
|
export interface GasSettings {
|
||||||
|
gasMode: GasMode;
|
||||||
|
setGasMode: (mode: GasMode) => void;
|
||||||
|
customGwei: string;
|
||||||
|
setCustomGwei: (value: string) => void;
|
||||||
|
effectiveMaxFee: string | null;
|
||||||
|
effectivePriorityFee: string | null;
|
||||||
|
displayGwei: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number): string {
|
||||||
|
if (n >= 1) return n.toFixed(2);
|
||||||
|
const s = n.toFixed(6);
|
||||||
|
return s.replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGasSettings(gasPriceData: GasPriceData | null): GasSettings {
|
||||||
|
const [gasMode, setGasMode] = useState<GasMode>('normal');
|
||||||
|
const [customGwei, setCustomGwei] = useState('');
|
||||||
|
|
||||||
|
const { effectiveMaxFee, effectivePriorityFee, displayGwei } = useMemo(() => {
|
||||||
|
if (gasMode === 'custom') {
|
||||||
|
const v = customGwei.trim();
|
||||||
|
if (!v || Number(v) <= 0) {
|
||||||
|
return { effectiveMaxFee: null, effectivePriorityFee: null, displayGwei: '-' };
|
||||||
|
}
|
||||||
|
const base = Number(v);
|
||||||
|
const priority = Math.max(0.01, base * 0.1);
|
||||||
|
return {
|
||||||
|
effectiveMaxFee: v,
|
||||||
|
effectivePriorityFee: fmt(priority),
|
||||||
|
displayGwei: `${v} gwei`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gasPriceData) {
|
||||||
|
return { effectiveMaxFee: null, effectivePriorityFee: null, displayGwei: '...' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = gasPriceData[gasMode];
|
||||||
|
return {
|
||||||
|
effectiveMaxFee: fmt(tier.maxFeePerGas),
|
||||||
|
effectivePriorityFee: fmt(tier.maxPriorityFeePerGas),
|
||||||
|
displayGwei: `${fmt(tier.maxFeePerGas)} gwei`,
|
||||||
|
};
|
||||||
|
}, [gasMode, customGwei, gasPriceData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gasMode,
|
||||||
|
setGasMode,
|
||||||
|
customGwei,
|
||||||
|
setCustomGwei,
|
||||||
|
effectiveMaxFee,
|
||||||
|
effectivePriorityFee,
|
||||||
|
displayGwei,
|
||||||
|
};
|
||||||
|
}
|
||||||
274
apps/web/src/hooks/useSwap.ts
Normal file
274
apps/web/src/hooks/useSwap.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ensureSwapApproval } from '@/lib/swap/approve';
|
||||||
|
import { executeSwap } from '@/lib/swap/execute';
|
||||||
|
import { getSwapQuote, type SwapQuoteResult } from '@/lib/swap/quote';
|
||||||
|
import { getSolSwapQuote, type SolSwapQuoteResult } from '@/lib/swap/sol/quote';
|
||||||
|
import { executeSolSwap } from '@/lib/swap/sol/execute';
|
||||||
|
import { getTrxSwapQuote, type TrxSwapQuoteResult } from '@/lib/swap/trx/quote';
|
||||||
|
import { executeTrxSwap } from '@/lib/swap/trx/execute';
|
||||||
|
import { getBscSwapQuote, type BscSwapQuoteResult } from '@/lib/swap/bsc/quote';
|
||||||
|
import { executeBscSwap } from '@/lib/swap/bsc/execute';
|
||||||
|
import { mapSwapError } from '@/lib/swap/errors';
|
||||||
|
import type { SwapQuoteRequest } from '@/lib/swap/constants';
|
||||||
|
import { type SwapChain, getSlippageBpsForChain } from '@/lib/swap/constants';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
|
||||||
|
const ESTIMATE_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
export type SwapStatus = 'idle' | 'quoting' | 'quoted' | 'approving' | 'swapping' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface LiveEstimate {
|
||||||
|
amountOut: string;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnyQuoteResult = SwapQuoteResult | SolSwapQuoteResult | TrxSwapQuoteResult | BscSwapQuoteResult;
|
||||||
|
|
||||||
|
export interface MultiChainSwapRequest {
|
||||||
|
chain: SwapChain;
|
||||||
|
fromSymbol: string;
|
||||||
|
toSymbol: string;
|
||||||
|
amount: string;
|
||||||
|
slippageBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSwap() {
|
||||||
|
const wallets = useAuthStore((state) => state.wallets);
|
||||||
|
const [status, setStatus] = useState<SwapStatus>('idle');
|
||||||
|
const [quote, setQuote] = useState<AnyQuoteResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [txHash, setTxHash] = useState<string | null>(null);
|
||||||
|
const [approvalHashes, setApprovalHashes] = useState<string[]>([]);
|
||||||
|
const [liveEstimate, setLiveEstimate] = useState<LiveEstimate | null>(null);
|
||||||
|
const [chain, setChain] = useState<SwapChain>('ETH');
|
||||||
|
const estimateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const currentWallet = useMemo(
|
||||||
|
() => wallets.find((w) => w.chain === chain) ?? null,
|
||||||
|
[wallets, chain]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchQuote = async (request: MultiChainSwapRequest) => {
|
||||||
|
setStatus('quoting');
|
||||||
|
setError(null);
|
||||||
|
setTxHash(null);
|
||||||
|
setApprovalHashes([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextQuote = await fetchQuoteForChain(request);
|
||||||
|
setQuote(nextQuote);
|
||||||
|
setStatus('quoted');
|
||||||
|
return nextQuote;
|
||||||
|
} catch (nextError) {
|
||||||
|
setQuote(null);
|
||||||
|
setStatus('error');
|
||||||
|
setError(mapSwapError(request.chain, nextError));
|
||||||
|
throw nextError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitSwap = async (
|
||||||
|
request: MultiChainSwapRequest,
|
||||||
|
maxFeeGwei?: string | null,
|
||||||
|
priorityFeeGwei?: string | null,
|
||||||
|
) => {
|
||||||
|
if (!currentWallet?.privateKey) {
|
||||||
|
const walletError = new Error(`${request.chain} private key is not available`);
|
||||||
|
setStatus('error');
|
||||||
|
setError(walletError.message);
|
||||||
|
throw walletError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quote) {
|
||||||
|
const quoteError = new Error('Get a quote before swapping');
|
||||||
|
setStatus('error');
|
||||||
|
setError(quoteError.message);
|
||||||
|
throw quoteError;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setApprovalHashes([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: { hash: string };
|
||||||
|
|
||||||
|
switch (request.chain) {
|
||||||
|
case 'ETH': {
|
||||||
|
// Existing ETH swap logic
|
||||||
|
if (request.fromSymbol !== 'ETH') {
|
||||||
|
setStatus('approving');
|
||||||
|
const approvalResult = await ensureSwapApproval({
|
||||||
|
privateKey: currentWallet.privateKey,
|
||||||
|
tokenSymbol: request.fromSymbol as any,
|
||||||
|
amount: request.amount,
|
||||||
|
maxFeeGwei,
|
||||||
|
priorityFeeGwei,
|
||||||
|
});
|
||||||
|
setApprovalHashes(approvalResult.approvalHashes);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('swapping');
|
||||||
|
const ethQuote = quote as SwapQuoteResult;
|
||||||
|
result = await executeSwap({
|
||||||
|
privateKey: currentWallet.privateKey,
|
||||||
|
request: request as SwapQuoteRequest,
|
||||||
|
quote: ethQuote,
|
||||||
|
maxFeeGwei,
|
||||||
|
priorityFeeGwei,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SOL': {
|
||||||
|
setStatus('swapping');
|
||||||
|
const solQuote = quote as SolSwapQuoteResult;
|
||||||
|
result = await executeSolSwap({
|
||||||
|
privateKeyHex: currentWallet.privateKey,
|
||||||
|
userPublicKey: currentWallet.address,
|
||||||
|
quoteResponse: solQuote.quoteResponse,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TRX': {
|
||||||
|
setStatus('swapping');
|
||||||
|
const trxQuote = quote as TrxSwapQuoteResult;
|
||||||
|
const trxResult = await executeTrxSwap({
|
||||||
|
privateKeyHex: currentWallet.privateKey,
|
||||||
|
from: request.fromSymbol,
|
||||||
|
to: request.toSymbol,
|
||||||
|
amount: trxQuote.amountInRaw,
|
||||||
|
amountOutMin: trxQuote.minimumAmountOutRaw,
|
||||||
|
userAddress: currentWallet.address,
|
||||||
|
});
|
||||||
|
setApprovalHashes(trxResult.approvalHashes);
|
||||||
|
result = trxResult;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'BSC': {
|
||||||
|
setStatus('swapping');
|
||||||
|
const bscQuote = quote as BscSwapQuoteResult;
|
||||||
|
const bscResult = await executeBscSwap({
|
||||||
|
privateKeyHex: currentWallet.privateKey,
|
||||||
|
from: request.fromSymbol,
|
||||||
|
to: request.toSymbol,
|
||||||
|
amount: bscQuote.amountIn,
|
||||||
|
amountOutMin: bscQuote.minimumAmountOutRaw,
|
||||||
|
userAddress: currentWallet.address,
|
||||||
|
});
|
||||||
|
setApprovalHashes(bscResult.approvalHashes);
|
||||||
|
result = bscResult;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTxHash(result.hash);
|
||||||
|
setStatus('success');
|
||||||
|
return result;
|
||||||
|
} catch (nextError) {
|
||||||
|
setStatus('error');
|
||||||
|
setError(mapSwapError(request.chain, nextError));
|
||||||
|
throw nextError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const estimateOutput = useCallback((request: MultiChainSwapRequest) => {
|
||||||
|
if (
|
||||||
|
request.fromSymbol === request.toSymbol ||
|
||||||
|
!request.amount ||
|
||||||
|
Number(request.amount) <= 0 ||
|
||||||
|
request.slippageBps <= 0
|
||||||
|
) {
|
||||||
|
setLiveEstimate(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (estimateTimeoutRef.current) {
|
||||||
|
clearTimeout(estimateTimeoutRef.current);
|
||||||
|
estimateTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLiveEstimate({ amountOut: '', loading: true });
|
||||||
|
|
||||||
|
estimateTimeoutRef.current = setTimeout(async () => {
|
||||||
|
estimateTimeoutRef.current = null;
|
||||||
|
try {
|
||||||
|
const result = await fetchQuoteForChain(request);
|
||||||
|
setLiveEstimate({
|
||||||
|
amountOut: result.amountOutFormatted,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setLiveEstimate(null);
|
||||||
|
}
|
||||||
|
}, ESTIMATE_DEBOUNCE_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetSwap = useCallback(() => {
|
||||||
|
if (estimateTimeoutRef.current) {
|
||||||
|
clearTimeout(estimateTimeoutRef.current);
|
||||||
|
estimateTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setStatus('idle');
|
||||||
|
setQuote(null);
|
||||||
|
setError(null);
|
||||||
|
setTxHash(null);
|
||||||
|
setApprovalHashes([]);
|
||||||
|
setLiveEstimate(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
quote,
|
||||||
|
error,
|
||||||
|
txHash,
|
||||||
|
approvalHashes,
|
||||||
|
liveEstimate,
|
||||||
|
chain,
|
||||||
|
setChain,
|
||||||
|
currentWallet,
|
||||||
|
fetchQuote,
|
||||||
|
submitSwap,
|
||||||
|
resetSwap,
|
||||||
|
estimateOutput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchQuoteForChain(request: MultiChainSwapRequest): Promise<AnyQuoteResult> {
|
||||||
|
switch (request.chain) {
|
||||||
|
case 'ETH':
|
||||||
|
return getSwapQuote({
|
||||||
|
fromSymbol: request.fromSymbol as any,
|
||||||
|
toSymbol: request.toSymbol as any,
|
||||||
|
amount: request.amount,
|
||||||
|
slippageBps: request.slippageBps,
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'SOL':
|
||||||
|
return getSolSwapQuote({
|
||||||
|
fromSymbol: request.fromSymbol,
|
||||||
|
toSymbol: request.toSymbol,
|
||||||
|
amount: request.amount,
|
||||||
|
slippageBps: request.slippageBps,
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'TRX':
|
||||||
|
return getTrxSwapQuote({
|
||||||
|
fromSymbol: request.fromSymbol,
|
||||||
|
toSymbol: request.toSymbol,
|
||||||
|
amount: request.amount,
|
||||||
|
slippageBps: request.slippageBps,
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'BSC':
|
||||||
|
return getBscSwapQuote({
|
||||||
|
fromSymbol: request.fromSymbol,
|
||||||
|
toSymbol: request.toSymbol,
|
||||||
|
amount: request.amount,
|
||||||
|
slippageBps: request.slippageBps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/web/src/lib/api.ts
Normal file
28
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { webEnv } from './env';
|
||||||
|
|
||||||
|
const API_URL = webEnv.apiUrl;
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
getWallets: () => request<any>('/api/wallets'),
|
||||||
|
};
|
||||||
185
apps/web/src/lib/balances/bsc-balances.ts
Normal file
185
apps/web/src/lib/balances/bsc-balances.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
|
||||||
|
|
||||||
|
const BSC_CHAIN_ID = 56;
|
||||||
|
|
||||||
|
const BSC_BALANCE_TIMEOUT_MS = 6_000;
|
||||||
|
const BSC_RPC_HEALTHCHECK_TIMEOUT_MS = 4_000;
|
||||||
|
const BEP20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
|
||||||
|
|
||||||
|
const BSC_RPC_CANDIDATES = dedupeUrls([
|
||||||
|
webEnv.bscRpcUrl,
|
||||||
|
'https://bsc-dataseed1.defibit.io',
|
||||||
|
'https://bsc-dataseed1.ninicoin.io',
|
||||||
|
'https://bsc-dataseed.binance.org',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BSC_TOKENS: TokenDefinition[] = [
|
||||||
|
{
|
||||||
|
chain: 'BSC',
|
||||||
|
symbol: 'BNB',
|
||||||
|
decimals: 18,
|
||||||
|
contractAddress: 'native',
|
||||||
|
coinGeckoId: 'binancecoin',
|
||||||
|
isNative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'BSC',
|
||||||
|
symbol: 'USDT',
|
||||||
|
decimals: 18,
|
||||||
|
contractAddress: '0x55d398326f99059fF775485246999027B3197955',
|
||||||
|
coinGeckoId: 'tether',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'BSC',
|
||||||
|
symbol: 'DOGE',
|
||||||
|
decimals: 8,
|
||||||
|
contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
|
||||||
|
coinGeckoId: 'dogecoin',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function fetchBscBalances(address: string): Promise<ChainBalance> {
|
||||||
|
let provider: ethers.providers.StaticJsonRpcProvider;
|
||||||
|
try {
|
||||||
|
provider = await getHealthyBscProvider();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
chain: 'BSC',
|
||||||
|
address,
|
||||||
|
tokens: BSC_TOKENS.map(createEmptyTokenBalance),
|
||||||
|
totalUsd: null,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
BSC_TOKENS.map(async (token) => readBscTokenBalance(provider, address, token))
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens: TokenBalance[] = [];
|
||||||
|
const errors: Array<{ symbol: string; message: string }> = [];
|
||||||
|
|
||||||
|
settled.forEach((result, index) => {
|
||||||
|
const token = BSC_TOKENS[index];
|
||||||
|
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
tokens.push(result.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push(createEmptyTokenBalance(token));
|
||||||
|
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueMessages = [...new Set(errors.map((item) => item.message))];
|
||||||
|
const error =
|
||||||
|
errors.length === BSC_TOKENS.length && uniqueMessages.length === 1
|
||||||
|
? uniqueMessages[0]
|
||||||
|
: errors.length
|
||||||
|
? errors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain: 'BSC',
|
||||||
|
address,
|
||||||
|
tokens,
|
||||||
|
totalUsd: null,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHealthyBscProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
|
||||||
|
let lastError: unknown = new Error('No BSC RPC endpoints configured');
|
||||||
|
for (const rpcUrl of BSC_RPC_CANDIDATES) {
|
||||||
|
const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, BSC_CHAIN_ID);
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
provider.getBlockNumber(),
|
||||||
|
BSC_RPC_HEALTHCHECK_TIMEOUT_MS,
|
||||||
|
`BSC RPC health-check timed out for ${rpcUrl}`
|
||||||
|
);
|
||||||
|
return provider;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBscTokenBalance(provider: ethers.providers.StaticJsonRpcProvider, address: string, token: TokenDefinition): Promise<TokenBalance> {
|
||||||
|
if (token.isNative) {
|
||||||
|
const balance = await withTimeout(
|
||||||
|
provider.getBalance(address),
|
||||||
|
BSC_BALANCE_TIMEOUT_MS,
|
||||||
|
'BNB balance request timed out'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
balanceRaw: balance.toString(),
|
||||||
|
balanceFormatted: ethers.utils.formatEther(balance),
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = new ethers.Contract(token.contractAddress, BEP20_ABI, provider);
|
||||||
|
const balance = (await withTimeout(
|
||||||
|
contract.balanceOf(address),
|
||||||
|
BSC_BALANCE_TIMEOUT_MS,
|
||||||
|
`${token.symbol} balance request timed out`
|
||||||
|
)) as ethers.BigNumber;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
balanceRaw: balance.toString(),
|
||||||
|
balanceFormatted: ethers.utils.formatUnits(balance, token.decimals),
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
balanceRaw: '0',
|
||||||
|
balanceFormatted: '0',
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
|
||||||
|
promise
|
||||||
|
.then((value) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(value);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
if (error.message.includes('Failed to fetch')) {
|
||||||
|
return 'BSC RPC is temporarily unavailable';
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unable to load token balance';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeUrls(urls: string[]): string[] {
|
||||||
|
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
101
apps/web/src/lib/balances/btc-balances.ts
Normal file
101
apps/web/src/lib/balances/btc-balances.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import type { ChainBalance, TokenDefinition } from './types';
|
||||||
|
|
||||||
|
const BTC_BALANCE_TIMEOUT_MS = 12_000;
|
||||||
|
|
||||||
|
const BTC_TOKEN: TokenDefinition = {
|
||||||
|
chain: 'BTC',
|
||||||
|
symbol: 'BTC',
|
||||||
|
decimals: 8,
|
||||||
|
contractAddress: 'native',
|
||||||
|
coinGeckoId: 'bitcoin',
|
||||||
|
isNative: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BlockstreamAddressResponse {
|
||||||
|
chain_stats?: {
|
||||||
|
funded_txo_sum?: number;
|
||||||
|
spent_txo_sum?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBtcBalances(address: string): Promise<ChainBalance> {
|
||||||
|
try {
|
||||||
|
const response = await fetchJsonWithTimeout<BlockstreamAddressResponse>(
|
||||||
|
`${webEnv.btcApiUrl}/address/${address}`,
|
||||||
|
BTC_BALANCE_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
const funded = response.chain_stats?.funded_txo_sum ?? 0;
|
||||||
|
const spent = response.chain_stats?.spent_txo_sum ?? 0;
|
||||||
|
const sats = Math.max(funded - spent, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain: 'BTC',
|
||||||
|
address,
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
...BTC_TOKEN,
|
||||||
|
balanceRaw: sats.toString(),
|
||||||
|
balanceFormatted: formatFixedBalance(sats, BTC_TOKEN.decimals),
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalUsd: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
chain: 'BTC',
|
||||||
|
address,
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
...BTC_TOKEN,
|
||||||
|
balanceRaw: '0',
|
||||||
|
balanceFormatted: '0',
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalUsd: null,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJsonWithTimeout<T>(url: string, timeoutMs: number): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`BTC API returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFixedBalance(rawValue: number, decimals: number): string {
|
||||||
|
if (rawValue === 0) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (rawValue / 10 ** decimals).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unable to load BTC balance';
|
||||||
|
}
|
||||||
257
apps/web/src/lib/balances/eth-balances.ts
Normal file
257
apps/web/src/lib/balances/eth-balances.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
|
||||||
|
|
||||||
|
const ETH_CHAIN_ID = 1;
|
||||||
|
|
||||||
|
const ETH_BALANCE_TIMEOUT_MS = 6_000;
|
||||||
|
const ETH_RPC_HEALTHCHECK_TIMEOUT_MS = 4_000;
|
||||||
|
const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
|
||||||
|
|
||||||
|
const ETH_RPC_CANDIDATES = dedupeUrls([
|
||||||
|
webEnv.ethRpcUrl,
|
||||||
|
'https://ethereum-rpc.publicnode.com',
|
||||||
|
'https://rpc.ankr.com/eth',
|
||||||
|
'https://eth.llamarpc.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ETH_TOKENS: TokenDefinition[] = [
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'ETH',
|
||||||
|
decimals: 18,
|
||||||
|
contractAddress: 'native',
|
||||||
|
coinGeckoId: 'ethereum',
|
||||||
|
isNative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'USDT',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
|
||||||
|
coinGeckoId: 'tether',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'USDC',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
||||||
|
coinGeckoId: 'usd-coin',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'XAUT',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: '0x68749665FF8D2d112Fa859AA293F07A622782F38',
|
||||||
|
coinGeckoId: 'tether-gold',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'UNI',
|
||||||
|
decimals: 18,
|
||||||
|
contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984',
|
||||||
|
coinGeckoId: 'uniswap',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'PEPE',
|
||||||
|
decimals: 18,
|
||||||
|
contractAddress: '0x6982508145454Ce325dDbE47a25d4ec3d2311933',
|
||||||
|
coinGeckoId: 'pepe',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'stETH',
|
||||||
|
decimals: 18,
|
||||||
|
contractAddress: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84',
|
||||||
|
coinGeckoId: 'staked-ether',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'SHIB',
|
||||||
|
decimals: 18,
|
||||||
|
contractAddress: '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE',
|
||||||
|
coinGeckoId: 'shiba-inu',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'LINK',
|
||||||
|
decimals: 18,
|
||||||
|
contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA',
|
||||||
|
coinGeckoId: 'chainlink',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'POL',
|
||||||
|
decimals: 18,
|
||||||
|
contractAddress: '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6',
|
||||||
|
coinGeckoId: 'polygon-ecosystem-token',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'WLFI',
|
||||||
|
decimals: 18,
|
||||||
|
contractAddress: '0x66f85E3865D0cFDC009acf6280a8621f12e46CCf',
|
||||||
|
coinGeckoId: 'world-liberty-financial',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'ETH',
|
||||||
|
symbol: 'AAVE',
|
||||||
|
decimals: 18,
|
||||||
|
contractAddress: '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9',
|
||||||
|
coinGeckoId: 'aave',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function fetchEthBalances(address: string): Promise<ChainBalance> {
|
||||||
|
let provider: ethers.providers.StaticJsonRpcProvider;
|
||||||
|
try {
|
||||||
|
provider = await getHealthyEthProvider();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
chain: 'ETH',
|
||||||
|
address,
|
||||||
|
tokens: ETH_TOKENS.map(createEmptyTokenBalance),
|
||||||
|
totalUsd: null,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
ETH_TOKENS.map(async (token) => readEthTokenBalance(provider, address, token))
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens: TokenBalance[] = [];
|
||||||
|
const errors: Array<{ symbol: string; message: string }> = [];
|
||||||
|
|
||||||
|
settled.forEach((result, index) => {
|
||||||
|
const token = ETH_TOKENS[index];
|
||||||
|
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
tokens.push(result.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push(createEmptyTokenBalance(token));
|
||||||
|
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueMessages = [...new Set(errors.map((item) => item.message))];
|
||||||
|
const error =
|
||||||
|
errors.length === ETH_TOKENS.length && uniqueMessages.length === 1
|
||||||
|
? uniqueMessages[0]
|
||||||
|
: errors.length
|
||||||
|
? errors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain: 'ETH',
|
||||||
|
address,
|
||||||
|
tokens,
|
||||||
|
totalUsd: null,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHealthyEthProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
|
||||||
|
let lastError: unknown = new Error('No Ethereum RPC endpoints configured');
|
||||||
|
for (const rpcUrl of ETH_RPC_CANDIDATES) {
|
||||||
|
const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, ETH_CHAIN_ID);
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
provider.getBlockNumber(),
|
||||||
|
ETH_RPC_HEALTHCHECK_TIMEOUT_MS,
|
||||||
|
`ETH RPC health-check timed out for ${rpcUrl}`
|
||||||
|
);
|
||||||
|
return provider;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readEthTokenBalance(provider: ethers.providers.StaticJsonRpcProvider, address: string, token: TokenDefinition): Promise<TokenBalance> {
|
||||||
|
if (token.isNative) {
|
||||||
|
const balance = await withTimeout(
|
||||||
|
provider.getBalance(address),
|
||||||
|
ETH_BALANCE_TIMEOUT_MS,
|
||||||
|
'ETH balance request timed out'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
balanceRaw: balance.toString(),
|
||||||
|
balanceFormatted: ethers.utils.formatEther(balance),
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = new ethers.Contract(token.contractAddress, ERC20_ABI, provider);
|
||||||
|
const balance = (await withTimeout(
|
||||||
|
contract.balanceOf(address),
|
||||||
|
ETH_BALANCE_TIMEOUT_MS,
|
||||||
|
`${token.symbol} balance request timed out`
|
||||||
|
)) as ethers.BigNumber;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
balanceRaw: balance.toString(),
|
||||||
|
balanceFormatted: ethers.utils.formatUnits(balance, token.decimals),
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
balanceRaw: '0',
|
||||||
|
balanceFormatted: '0',
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
|
||||||
|
promise
|
||||||
|
.then((value) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(value);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
if (error.message.includes('Failed to fetch')) {
|
||||||
|
return 'Ethereum RPC is temporarily unavailable';
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unable to load token balance';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeUrls(urls: string[]): string[] {
|
||||||
|
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
120
apps/web/src/lib/balances/index.ts
Normal file
120
apps/web/src/lib/balances/index.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { DerivedWallet } from '@/lib/crypto/derive-keys';
|
||||||
|
import { fetchBtcBalances } from './btc-balances';
|
||||||
|
import { fetchEthBalances } from './eth-balances';
|
||||||
|
import { fetchUsdPrices } from './prices';
|
||||||
|
import { fetchSolBalances } from './sol-balances';
|
||||||
|
import { fetchTrxBalances } from './trx-balances';
|
||||||
|
import { fetchBscBalances } from './bsc-balances';
|
||||||
|
import type { BalanceChain, ChainBalance, PortfolioBalance, TokenBalance } from './types';
|
||||||
|
|
||||||
|
const SUPPORTED_CHAINS: BalanceChain[] = ['ETH', 'BTC', 'SOL', 'TRX', 'BSC'];
|
||||||
|
|
||||||
|
const balanceFetchers: Record<BalanceChain, (address: string) => Promise<ChainBalance>> = {
|
||||||
|
ETH: fetchEthBalances,
|
||||||
|
BTC: fetchBtcBalances,
|
||||||
|
SOL: fetchSolBalances,
|
||||||
|
TRX: fetchTrxBalances,
|
||||||
|
BSC: fetchBscBalances,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAllBalances(wallets: DerivedWallet[]): Promise<PortfolioBalance> {
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
SUPPORTED_CHAINS.map(async (chain) => {
|
||||||
|
const wallet = wallets.find((item) => item.chain === chain);
|
||||||
|
|
||||||
|
if (!wallet) {
|
||||||
|
return createMissingChainBalance(chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
return balanceFetchers[chain](wallet.address);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawChains = settled.map((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMissingChainBalance(SUPPORTED_CHAINS[index], getErrorMessage(result.reason));
|
||||||
|
});
|
||||||
|
|
||||||
|
let prices: Record<string, number> = {};
|
||||||
|
let priceError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const coinIds = rawChains.flatMap((chain) => chain.tokens.map((token) => token.coinGeckoId));
|
||||||
|
prices = await fetchUsdPrices(coinIds);
|
||||||
|
} catch (error) {
|
||||||
|
priceError = getErrorMessage(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chains = rawChains.map((chain) => enrichChain(chain, prices));
|
||||||
|
|
||||||
|
return {
|
||||||
|
chains,
|
||||||
|
totalUsd: sumNullable(chains.map((chain) => chain.totalUsd)),
|
||||||
|
errors: chains.reduce<PortfolioBalance['errors']>((acc, chain) => {
|
||||||
|
if (chain.error && chain.error !== '__transient__') {
|
||||||
|
acc[chain.chain] = chain.error;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
priceError,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichChain(chain: ChainBalance, prices: Record<string, number>): ChainBalance {
|
||||||
|
const tokens = chain.tokens.map((token) => enrichToken(token, prices));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...chain,
|
||||||
|
tokens,
|
||||||
|
totalUsd: sumNullable(tokens.map((token) => token.valueUsd)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichToken(token: TokenBalance, prices: Record<string, number>): TokenBalance {
|
||||||
|
const priceUsd = prices[token.coinGeckoId];
|
||||||
|
|
||||||
|
if (typeof priceUsd !== 'number') {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const balance = Number(token.balanceFormatted);
|
||||||
|
const valueUsd = Number.isFinite(balance) ? balance * priceUsd : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
priceUsd,
|
||||||
|
valueUsd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMissingChainBalance(chain: BalanceChain, error = 'Wallet not available'): ChainBalance {
|
||||||
|
return {
|
||||||
|
chain,
|
||||||
|
address: '',
|
||||||
|
tokens: [],
|
||||||
|
totalUsd: null,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumNullable(values: Array<number | null>): number | null {
|
||||||
|
const filtered = values.filter((value): value is number => typeof value === 'number');
|
||||||
|
|
||||||
|
if (!filtered.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.reduce((total, value) => total + value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unable to load balances';
|
||||||
|
}
|
||||||
70
apps/web/src/lib/balances/prices.ts
Normal file
70
apps/web/src/lib/balances/prices.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
const PRICE_CACHE_TTL_MS = 60_000;
|
||||||
|
const PRICE_REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
let cachedPrices: Record<string, number> | null = null;
|
||||||
|
let cachedAt = 0;
|
||||||
|
|
||||||
|
interface CoinGeckoPriceResponse {
|
||||||
|
[coinId: string]: {
|
||||||
|
usd?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsdPrices(coinIds: string[]): Promise<Record<string, number>> {
|
||||||
|
const uniqueCoinIds = Array.from(new Set(coinIds.filter(Boolean)));
|
||||||
|
|
||||||
|
if (!uniqueCoinIds.length) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cachedPrices &&
|
||||||
|
Date.now() - cachedAt < PRICE_CACHE_TTL_MS &&
|
||||||
|
uniqueCoinIds.every((coinId) => coinId in cachedPrices!)
|
||||||
|
) {
|
||||||
|
return uniqueCoinIds.reduce<Record<string, number>>((acc, coinId) => {
|
||||||
|
acc[coinId] = cachedPrices![coinId];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), PRICE_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL('https://api.coingecko.com/api/v3/simple/price');
|
||||||
|
url.searchParams.set('ids', uniqueCoinIds.join(','));
|
||||||
|
url.searchParams.set('vs_currencies', 'usd');
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`CoinGecko returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as CoinGeckoPriceResponse;
|
||||||
|
const prices = uniqueCoinIds.reduce<Record<string, number>>((acc, coinId) => {
|
||||||
|
const price = payload[coinId]?.usd;
|
||||||
|
if (typeof price === 'number') {
|
||||||
|
acc[coinId] = price;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
cachedPrices = {
|
||||||
|
...(cachedPrices ?? {}),
|
||||||
|
...prices,
|
||||||
|
};
|
||||||
|
cachedAt = Date.now();
|
||||||
|
|
||||||
|
return prices;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
325
apps/web/src/lib/balances/sol-balances.ts
Normal file
325
apps/web/src/lib/balances/sol-balances.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { Connection, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
|
||||||
|
|
||||||
|
const SOL_BALANCE_TIMEOUT_MS = 6_000;
|
||||||
|
const SOL_RPC_CANDIDATES = dedupeUrls([
|
||||||
|
webEnv.solRpcUrl,
|
||||||
|
'https://solana.publicnode.com',
|
||||||
|
'https://api.mainnet-beta.solana.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SOL_TOKENS: TokenDefinition[] = [
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'SOL',
|
||||||
|
decimals: 9,
|
||||||
|
contractAddress: 'native',
|
||||||
|
coinGeckoId: 'solana',
|
||||||
|
isNative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'USDT',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
|
||||||
|
coinGeckoId: 'tether',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'USDC',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
|
||||||
|
coinGeckoId: 'usd-coin',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'PUMP',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn',
|
||||||
|
coinGeckoId: 'pump',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'JUP',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN',
|
||||||
|
coinGeckoId: 'jupiter-exchange-solana',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'WIF',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm',
|
||||||
|
coinGeckoId: 'dogwifcoin',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'POPCAT',
|
||||||
|
decimals: 9,
|
||||||
|
contractAddress: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr',
|
||||||
|
coinGeckoId: 'popcat',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'TRUMP',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN',
|
||||||
|
coinGeckoId: 'official-trump',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'PYTH',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3',
|
||||||
|
coinGeckoId: 'pyth-network',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'JTO',
|
||||||
|
decimals: 9,
|
||||||
|
contractAddress: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL',
|
||||||
|
coinGeckoId: 'jito-governance-token',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'W',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ',
|
||||||
|
coinGeckoId: 'wormhole',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'BONK',
|
||||||
|
decimals: 5,
|
||||||
|
contractAddress: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
|
||||||
|
coinGeckoId: 'bonk',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'ORCA',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE',
|
||||||
|
coinGeckoId: 'orca',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'PENGU',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv',
|
||||||
|
coinGeckoId: 'pudgy-penguins',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'SOL',
|
||||||
|
symbol: 'RAY',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R',
|
||||||
|
coinGeckoId: 'raydium',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function fetchSolBalances(address: string): Promise<ChainBalance> {
|
||||||
|
const owner = new PublicKey(address);
|
||||||
|
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
SOL_TOKENS.map(async (token) => readSolTokenWithFallback(owner, token))
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens: TokenBalance[] = [];
|
||||||
|
const errors: Array<{ symbol: string; message: string }> = [];
|
||||||
|
|
||||||
|
settled.forEach((result, index) => {
|
||||||
|
const token = SOL_TOKENS[index];
|
||||||
|
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
tokens.push(result.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push(createEmptyTokenBalance(token));
|
||||||
|
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Separate transient (rate-limit, access restricted) from permanent errors
|
||||||
|
const permanentErrors = errors.filter((e) => !isTransientError(e.message));
|
||||||
|
const transientErrors = errors.filter((e) => isTransientError(e.message));
|
||||||
|
|
||||||
|
const uniqueMessages = [...new Set(permanentErrors.map((item) => item.message))];
|
||||||
|
let error: string | null =
|
||||||
|
permanentErrors.length === SOL_TOKENS.length && uniqueMessages.length === 1
|
||||||
|
? uniqueMessages[0]
|
||||||
|
: permanentErrors.length
|
||||||
|
? permanentErrors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// If some/all errors were transient, mark chain so store keeps previous balances
|
||||||
|
// '__transient__' is an internal marker — not displayed in UI
|
||||||
|
if (!error && transientErrors.length > 0) {
|
||||||
|
error = '__transient__';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain: 'SOL',
|
||||||
|
address,
|
||||||
|
tokens,
|
||||||
|
totalUsd: null,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSolTokenWithFallback(
|
||||||
|
owner: PublicKey,
|
||||||
|
token: TokenDefinition
|
||||||
|
): Promise<TokenBalance> {
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (const rpcUrl of SOL_RPC_CANDIDATES) {
|
||||||
|
try {
|
||||||
|
const connection = new Connection(rpcUrl, 'confirmed');
|
||||||
|
return await readSolTokenBalance(connection, owner, token);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (isMintNotFoundError(error)) {
|
||||||
|
return createEmptyTokenBalance(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSolTokenBalance(
|
||||||
|
connection: Connection,
|
||||||
|
owner: PublicKey,
|
||||||
|
token: TokenDefinition
|
||||||
|
): Promise<TokenBalance> {
|
||||||
|
if (token.isNative) {
|
||||||
|
const lamports = await withTimeout(
|
||||||
|
connection.getBalance(owner),
|
||||||
|
SOL_BALANCE_TIMEOUT_MS,
|
||||||
|
'SOL balance request timed out'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
balanceRaw: lamports.toString(),
|
||||||
|
balanceFormatted: (lamports / LAMPORTS_PER_SOL).toString(),
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mint = new PublicKey(token.contractAddress);
|
||||||
|
const response = await withTimeout(
|
||||||
|
connection.getParsedTokenAccountsByOwner(owner, { mint }),
|
||||||
|
SOL_BALANCE_TIMEOUT_MS,
|
||||||
|
`${token.symbol} balance request timed out`
|
||||||
|
);
|
||||||
|
|
||||||
|
const amountRaw = response.value.reduce((sum, account) => {
|
||||||
|
const parsed = account.account.data.parsed;
|
||||||
|
const amount = parsed.info.tokenAmount.amount;
|
||||||
|
return sum + BigInt(amount);
|
||||||
|
}, 0n);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
balanceRaw: amountRaw.toString(),
|
||||||
|
balanceFormatted: formatBigIntBalance(amountRaw, token.decimals),
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMintNotFoundError(error: unknown): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const msg = error.message;
|
||||||
|
return msg.includes('could not find mint') || msg.includes('Invalid param');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
balanceRaw: '0',
|
||||||
|
balanceFormatted: '0',
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBigIntBalance(rawValue: bigint, decimals: number): string {
|
||||||
|
if (rawValue === 0n) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const divisor = 10n ** BigInt(decimals);
|
||||||
|
const whole = rawValue / divisor;
|
||||||
|
const fraction = rawValue % divisor;
|
||||||
|
|
||||||
|
if (fraction === 0n) {
|
||||||
|
return whole.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
|
||||||
|
promise
|
||||||
|
.then((value) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(value);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTransientError(msg: string): boolean {
|
||||||
|
return msg.includes('access restricted') ||
|
||||||
|
msg.includes('temporarily unavailable') ||
|
||||||
|
msg.includes('timed out') ||
|
||||||
|
msg.includes('rate') ||
|
||||||
|
msg.includes('429') ||
|
||||||
|
msg.includes('403');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const msg = error.message;
|
||||||
|
if (msg.includes('403') || msg.includes('API key is not allowed')) {
|
||||||
|
return 'Solana RPC access restricted';
|
||||||
|
}
|
||||||
|
if (msg.includes('Failed to fetch')) {
|
||||||
|
return 'Solana RPC is temporarily unavailable';
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unable to load SOL balance';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function dedupeUrls(urls: string[]): string[] {
|
||||||
|
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
133
apps/web/src/lib/balances/trx-balances.ts
Normal file
133
apps/web/src/lib/balances/trx-balances.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import type { ChainBalance, TokenDefinition } from './types';
|
||||||
|
|
||||||
|
const TRX_BALANCE_TIMEOUT_MS = 12_000;
|
||||||
|
|
||||||
|
const TRX_TOKENS: TokenDefinition[] = [
|
||||||
|
{
|
||||||
|
chain: 'TRX',
|
||||||
|
symbol: 'TRX',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: 'native',
|
||||||
|
coinGeckoId: 'tron',
|
||||||
|
isNative: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chain: 'TRX',
|
||||||
|
symbol: 'USDT',
|
||||||
|
decimals: 6,
|
||||||
|
contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
|
||||||
|
coinGeckoId: 'tether',
|
||||||
|
isNative: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface TronGridAccountResponse {
|
||||||
|
data?: Array<{
|
||||||
|
balance?: number;
|
||||||
|
trc20?: Array<Record<string, string>>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTrxBalances(address: string): Promise<ChainBalance> {
|
||||||
|
try {
|
||||||
|
const response = await fetchJsonWithTimeout<TronGridAccountResponse>(
|
||||||
|
`${webEnv.apiUrl}/api/tron/account/${address}`,
|
||||||
|
TRX_BALANCE_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
const account = response.data?.[0];
|
||||||
|
const nativeRaw = account?.balance ?? 0;
|
||||||
|
const trc20Balances = account?.trc20 ?? [];
|
||||||
|
const usdtRaw = trc20Balances.reduce((current, entry) => {
|
||||||
|
const next = entry[TRX_TOKENS[1].contractAddress];
|
||||||
|
return next ?? current;
|
||||||
|
}, '0');
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain: 'TRX',
|
||||||
|
address,
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
...TRX_TOKENS[0],
|
||||||
|
balanceRaw: nativeRaw.toString(),
|
||||||
|
balanceFormatted: formatBalance(nativeRaw.toString(), TRX_TOKENS[0].decimals),
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...TRX_TOKENS[1],
|
||||||
|
balanceRaw: usdtRaw,
|
||||||
|
balanceFormatted: formatBalance(usdtRaw, TRX_TOKENS[1].decimals),
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalUsd: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[TRX] balance fetch failed:`, error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain: 'TRX',
|
||||||
|
address,
|
||||||
|
tokens: TRX_TOKENS.map((token) => ({
|
||||||
|
...token,
|
||||||
|
balanceRaw: '0',
|
||||||
|
balanceFormatted: '0',
|
||||||
|
priceUsd: null,
|
||||||
|
valueUsd: null,
|
||||||
|
})),
|
||||||
|
totalUsd: null,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJsonWithTimeout<T>(url: string, timeoutMs: number): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`TRON API returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBalance(rawValue: string, decimals: number): string {
|
||||||
|
const bigintValue = BigInt(rawValue || '0');
|
||||||
|
|
||||||
|
if (bigintValue === 0n) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const divisor = 10n ** BigInt(decimals);
|
||||||
|
const whole = bigintValue / divisor;
|
||||||
|
const fraction = bigintValue % divisor;
|
||||||
|
|
||||||
|
if (fraction === 0n) {
|
||||||
|
return whole.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unable to load TRX balance';
|
||||||
|
}
|
||||||
33
apps/web/src/lib/balances/types.ts
Normal file
33
apps/web/src/lib/balances/types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export type BalanceChain = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||||
|
|
||||||
|
export interface TokenDefinition {
|
||||||
|
chain: BalanceChain;
|
||||||
|
symbol: string;
|
||||||
|
decimals: number;
|
||||||
|
contractAddress: string | 'native';
|
||||||
|
coinGeckoId: string;
|
||||||
|
isNative: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenBalance extends TokenDefinition {
|
||||||
|
balanceRaw: string;
|
||||||
|
balanceFormatted: string;
|
||||||
|
priceUsd: number | null;
|
||||||
|
valueUsd: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainBalance {
|
||||||
|
chain: BalanceChain;
|
||||||
|
address: string;
|
||||||
|
tokens: TokenBalance[];
|
||||||
|
totalUsd: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioBalance {
|
||||||
|
chains: ChainBalance[];
|
||||||
|
totalUsd: number | null;
|
||||||
|
errors: Partial<Record<BalanceChain, string>>;
|
||||||
|
priceError: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
112
apps/web/src/lib/bridge/constants.ts
Normal file
112
apps/web/src/lib/bridge/constants.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
export const RELAY_PROXY_BASE_URL = '/api/relay';
|
||||||
|
export const RELAY_REQUEST_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
// ── Bridge platform fee (0.7%) ──
|
||||||
|
export const BRIDGE_FEE_BPS = 70; // 0.7%
|
||||||
|
export const BRIDGE_FEE_RECIPIENT_EVM = '0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718';
|
||||||
|
export const BRIDGE_FEE_RECIPIENT_SOL = 'Co43MKwqMRMCvhscVVrtQWvma87NEV7ba4cfo8cksgzJ';
|
||||||
|
export const BRIDGE_FEE_RECIPIENT_TRX = 'TYTfrem65362TFyQSARTheeYza1GQA37Ug';
|
||||||
|
|
||||||
|
// ─── Chain types ───
|
||||||
|
|
||||||
|
export type BridgeChainKey = 'ETH' | 'BSC' | 'SOL' | 'TRX';
|
||||||
|
|
||||||
|
export interface BridgeCurrencyConfig {
|
||||||
|
symbol: string;
|
||||||
|
address: string;
|
||||||
|
decimals: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BridgeChainConfig {
|
||||||
|
key: BridgeChainKey;
|
||||||
|
label: string;
|
||||||
|
chainId: number;
|
||||||
|
walletChain: 'ETH' | 'BSC' | 'SOL' | 'TRX';
|
||||||
|
explorerTxBaseUrl: string;
|
||||||
|
tokens: Record<string, BridgeCurrencyConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BRIDGE_CHAINS: Record<BridgeChainKey, BridgeChainConfig> = {
|
||||||
|
ETH: {
|
||||||
|
key: 'ETH',
|
||||||
|
label: 'Ethereum',
|
||||||
|
chainId: 1,
|
||||||
|
walletChain: 'ETH',
|
||||||
|
explorerTxBaseUrl: 'https://etherscan.io/tx/',
|
||||||
|
tokens: {
|
||||||
|
ETH: { symbol: 'ETH', address: '0x0000000000000000000000000000000000000000', decimals: 18 },
|
||||||
|
USDT: { symbol: 'USDT', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 },
|
||||||
|
USDC: { symbol: 'USDC', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SOL: {
|
||||||
|
key: 'SOL',
|
||||||
|
label: 'Solana',
|
||||||
|
chainId: 792703809,
|
||||||
|
walletChain: 'SOL',
|
||||||
|
explorerTxBaseUrl: 'https://solscan.io/tx/',
|
||||||
|
tokens: {
|
||||||
|
SOL: { symbol: 'SOL', address: '11111111111111111111111111111111', decimals: 9 },
|
||||||
|
USDT: { symbol: 'USDT', address: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 },
|
||||||
|
USDC: { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
BSC: {
|
||||||
|
key: 'BSC',
|
||||||
|
label: 'BNB Smart Chain',
|
||||||
|
chainId: 56,
|
||||||
|
walletChain: 'BSC',
|
||||||
|
explorerTxBaseUrl: 'https://bscscan.com/tx/',
|
||||||
|
tokens: {
|
||||||
|
BNB: { symbol: 'BNB', address: '0x0000000000000000000000000000000000000000', decimals: 18 },
|
||||||
|
USDT: { symbol: 'USDT', address: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TRX: {
|
||||||
|
key: 'TRX',
|
||||||
|
label: 'TRON',
|
||||||
|
chainId: 728126428,
|
||||||
|
walletChain: 'TRX',
|
||||||
|
explorerTxBaseUrl: 'https://tronscan.org/#/transaction/',
|
||||||
|
tokens: {
|
||||||
|
USDT: { symbol: 'USDT', address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BRIDGE_CHAIN_OPTIONS: BridgeChainKey[] = ['ETH', 'BSC', 'SOL', 'TRX'];
|
||||||
|
|
||||||
|
// ─── Helpers ───
|
||||||
|
|
||||||
|
export function getDestinationChainOptions(sourceChain: BridgeChainKey): BridgeChainKey[] {
|
||||||
|
return BRIDGE_CHAIN_OPTIONS.filter((c) => c !== sourceChain);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenOptions(chainKey: BridgeChainKey): string[] {
|
||||||
|
return Object.keys(BRIDGE_CHAINS[chainKey].tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultToken(chainKey: BridgeChainKey): string {
|
||||||
|
const tokens = getTokenOptions(chainKey);
|
||||||
|
return tokens[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenConfig(chainKey: BridgeChainKey, tokenSymbol: string): BridgeCurrencyConfig {
|
||||||
|
const token = BRIDGE_CHAINS[chainKey].tokens[tokenSymbol];
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(`Token ${tokenSymbol} not found on ${BRIDGE_CHAINS[chainKey].label}`);
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Request type ───
|
||||||
|
|
||||||
|
export interface BridgeQuoteRequest {
|
||||||
|
sourceChain: BridgeChainKey;
|
||||||
|
sourceToken: string;
|
||||||
|
destChain: BridgeChainKey;
|
||||||
|
destToken: string;
|
||||||
|
amount: string;
|
||||||
|
userAddress: string;
|
||||||
|
recipientAddress: string;
|
||||||
|
}
|
||||||
482
apps/web/src/lib/bridge/execute.ts
Normal file
482
apps/web/src/lib/bridge/execute.ts
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
Keypair,
|
||||||
|
PublicKey,
|
||||||
|
TransactionInstruction,
|
||||||
|
TransactionMessage,
|
||||||
|
VersionedTransaction,
|
||||||
|
AddressLookupTableAccount,
|
||||||
|
} from '@solana/web3.js';
|
||||||
|
import { createEthProvider } from '@/lib/eth-provider';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import { BSC_GAS_PRICE } from '@/lib/crypto/bsc-constants';
|
||||||
|
import {
|
||||||
|
BRIDGE_CHAINS,
|
||||||
|
BRIDGE_FEE_BPS,
|
||||||
|
BRIDGE_FEE_RECIPIENT_EVM,
|
||||||
|
BRIDGE_FEE_RECIPIENT_SOL,
|
||||||
|
BRIDGE_FEE_RECIPIENT_TRX,
|
||||||
|
RELAY_PROXY_BASE_URL,
|
||||||
|
RELAY_REQUEST_TIMEOUT_MS,
|
||||||
|
type BridgeChainKey,
|
||||||
|
} from './constants';
|
||||||
|
import type { RelayQuoteResponse, RelayStep } from './quote';
|
||||||
|
|
||||||
|
const provider = createEthProvider();
|
||||||
|
|
||||||
|
// TYTfrem65362TFyQSARTheeYza1GQA37Ug → hex (20 bytes, no 0x prefix)
|
||||||
|
const BRIDGE_FEE_RECIPIENT_TRX_HEX = 'f6b4d4e650fc67982894f37ba97ab2496781ddb6';
|
||||||
|
|
||||||
|
interface ExecuteBridgeParams {
|
||||||
|
sourceChain: BridgeChainKey;
|
||||||
|
sourceToken: string;
|
||||||
|
originalAmount: string;
|
||||||
|
sourceTokenDecimals: number;
|
||||||
|
sourceTokenAddress: string;
|
||||||
|
privateKey: string;
|
||||||
|
quote: RelayQuoteResponse;
|
||||||
|
maxFeeGwei?: string | null;
|
||||||
|
priorityFeeGwei?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteBridgeResult {
|
||||||
|
requestId: string | null;
|
||||||
|
txHashes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
|
||||||
|
switch (params.sourceChain) {
|
||||||
|
case 'ETH':
|
||||||
|
return executeEvmBridge(params, provider);
|
||||||
|
case 'BSC':
|
||||||
|
return executeEvmBridge(params, new ethers.providers.StaticJsonRpcProvider(webEnv.bscRpcUrl, 56));
|
||||||
|
case 'SOL':
|
||||||
|
return executeSolBridge(params);
|
||||||
|
case 'TRX':
|
||||||
|
return executeTrxBridge(params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── EVM origin (existing logic) ───
|
||||||
|
|
||||||
|
async function executeEvmBridge(
|
||||||
|
params: ExecuteBridgeParams,
|
||||||
|
evmProvider: ethers.providers.Provider,
|
||||||
|
): Promise<ExecuteBridgeResult> {
|
||||||
|
const wallet = new ethers.Wallet(params.privateKey, evmProvider);
|
||||||
|
const isBsc = params.sourceChain === 'BSC';
|
||||||
|
const txHashes: string[] = [];
|
||||||
|
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
|
||||||
|
|
||||||
|
// ── Send 0.7% platform fee before bridge ──
|
||||||
|
await sendEvmBridgeFee(wallet, params, isBsc);
|
||||||
|
|
||||||
|
for (const step of params.quote.steps) {
|
||||||
|
if (!step.items?.length) continue;
|
||||||
|
|
||||||
|
for (const item of step.items) {
|
||||||
|
if (step.kind === 'signature') {
|
||||||
|
await executeSignatureStep(wallet, step, item.data);
|
||||||
|
} else {
|
||||||
|
const hash = await executeEvmTransactionStep(wallet, item.data, params.maxFeeGwei, params.priorityFeeGwei, isBsc);
|
||||||
|
txHashes.push(hash);
|
||||||
|
}
|
||||||
|
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestId, txHashes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeEvmTransactionStep(
|
||||||
|
wallet: ethers.Wallet,
|
||||||
|
data: Record<string, any>,
|
||||||
|
maxFeeGwei?: string | null,
|
||||||
|
priorityFeeGwei?: string | null,
|
||||||
|
isBsc?: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
const gasOverrides = isBsc
|
||||||
|
? { gasPrice: BSC_GAS_PRICE }
|
||||||
|
: maxFeeGwei?.trim()
|
||||||
|
? {
|
||||||
|
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
|
||||||
|
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...(data.maxFeePerGas ? { maxFeePerGas: ethers.BigNumber.from(data.maxFeePerGas) } : {}),
|
||||||
|
...(data.maxPriorityFeePerGas ? { maxPriorityFeePerGas: ethers.BigNumber.from(data.maxPriorityFeePerGas) } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await wallet.sendTransaction({
|
||||||
|
to: data.to,
|
||||||
|
data: data.data,
|
||||||
|
value: data.value ? ethers.BigNumber.from(data.value) : ethers.constants.Zero,
|
||||||
|
gasLimit: data.gas ? ethers.BigNumber.from(data.gas) : undefined,
|
||||||
|
...gasOverrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const receipt = await response.wait();
|
||||||
|
if (!receipt || receipt.status !== 1) {
|
||||||
|
throw new Error('Bridge transaction reverted');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bridge Fee Helpers ───
|
||||||
|
|
||||||
|
async function sendEvmBridgeFee(wallet: ethers.Wallet, params: ExecuteBridgeParams, isBsc?: boolean): Promise<void> {
|
||||||
|
const fullAmountRaw = ethers.utils.parseUnits(params.originalAmount, params.sourceTokenDecimals);
|
||||||
|
const feeAmount = fullAmountRaw.mul(BRIDGE_FEE_BPS).div(10000);
|
||||||
|
if (feeAmount.isZero()) return;
|
||||||
|
|
||||||
|
const gasOverrides = isBsc ? { gasPrice: BSC_GAS_PRICE } : {};
|
||||||
|
const isNative = params.sourceTokenAddress === '0x0000000000000000000000000000000000000000';
|
||||||
|
|
||||||
|
if (isNative) {
|
||||||
|
const tx = await wallet.sendTransaction({
|
||||||
|
to: BRIDGE_FEE_RECIPIENT_EVM,
|
||||||
|
value: feeAmount,
|
||||||
|
...gasOverrides,
|
||||||
|
});
|
||||||
|
await tx.wait();
|
||||||
|
} else {
|
||||||
|
const tokenContract = new ethers.Contract(
|
||||||
|
params.sourceTokenAddress,
|
||||||
|
['function transfer(address to, uint256 amount) returns (bool)'],
|
||||||
|
wallet,
|
||||||
|
);
|
||||||
|
const tx = await tokenContract.transfer(BRIDGE_FEE_RECIPIENT_EVM, feeAmount, gasOverrides);
|
||||||
|
await tx.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSolBridgeFee(
|
||||||
|
connection: Connection,
|
||||||
|
keypair: Keypair,
|
||||||
|
params: ExecuteBridgeParams,
|
||||||
|
): Promise<void> {
|
||||||
|
const { SystemProgram } = await import('@solana/web3.js');
|
||||||
|
|
||||||
|
const fullAmountRaw = BigInt(
|
||||||
|
Math.round(Number(params.originalAmount) * 10 ** params.sourceTokenDecimals),
|
||||||
|
);
|
||||||
|
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
|
||||||
|
if (feeAmount === 0n) return;
|
||||||
|
|
||||||
|
const feeRecipient = new PublicKey(BRIDGE_FEE_RECIPIENT_SOL);
|
||||||
|
const isNative = params.sourceTokenAddress === '11111111111111111111111111111111';
|
||||||
|
|
||||||
|
// Bridge fee only supports native SOL transfers
|
||||||
|
// (SOL bridge primarily uses SOL, USDT, USDC — SPL fee handled off-chain if needed)
|
||||||
|
if (!isNative) return;
|
||||||
|
|
||||||
|
const instruction = SystemProgram.transfer({
|
||||||
|
fromPubkey: keypair.publicKey,
|
||||||
|
toPubkey: feeRecipient,
|
||||||
|
lamports: feeAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
|
||||||
|
const messageV0 = new TransactionMessage({
|
||||||
|
payerKey: keypair.publicKey,
|
||||||
|
recentBlockhash: latestBlockhash.blockhash,
|
||||||
|
instructions: [instruction],
|
||||||
|
}).compileToV0Message();
|
||||||
|
|
||||||
|
const tx = new VersionedTransaction(messageV0);
|
||||||
|
tx.sign([keypair]);
|
||||||
|
|
||||||
|
const sig = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: false });
|
||||||
|
await connection.confirmTransaction(
|
||||||
|
{ signature: sig, blockhash: latestBlockhash.blockhash, lastValidBlockHeight: latestBlockhash.lastValidBlockHeight },
|
||||||
|
'confirmed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTrxBridgeFee(
|
||||||
|
signingKey: ethers.utils.SigningKey,
|
||||||
|
apiUrl: string,
|
||||||
|
params: ExecuteBridgeParams,
|
||||||
|
): Promise<void> {
|
||||||
|
const decimals = params.sourceTokenDecimals;
|
||||||
|
const fullAmountRaw = BigInt(
|
||||||
|
Math.round(Number(params.originalAmount) * 10 ** decimals),
|
||||||
|
);
|
||||||
|
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
|
||||||
|
if (feeAmount === 0n) return;
|
||||||
|
|
||||||
|
// TRX bridge only supports USDT (TRC-20) — build a TRC20 transfer via API
|
||||||
|
// Use the tron proxy to build a transfer tx, sign it, and broadcast
|
||||||
|
const buildResp = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
owner_address: params.quote.steps[0]?.items?.[0]?.data?.parameter?.owner_address
|
||||||
|
?? ethers.utils.computeAddress(signingKey.publicKey).toLowerCase(),
|
||||||
|
contract_address: params.sourceTokenAddress,
|
||||||
|
function_selector: 'transfer(address,uint256)',
|
||||||
|
parameter:
|
||||||
|
BRIDGE_FEE_RECIPIENT_TRX_HEX.padStart(64, '0') +
|
||||||
|
feeAmount.toString(16).padStart(64, '0'),
|
||||||
|
call_value: 0,
|
||||||
|
fee_limit: 100000000,
|
||||||
|
visible: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!buildResp.ok) return; // Fee transfer is best-effort; don't block bridge
|
||||||
|
|
||||||
|
const buildResult = await buildResp.json();
|
||||||
|
const tx = buildResult.transaction;
|
||||||
|
if (!tx?.txID) return;
|
||||||
|
|
||||||
|
// Sign and broadcast
|
||||||
|
const digest = ethers.utils.arrayify('0x' + tx.txID);
|
||||||
|
const signature = signingKey.signDigest(digest);
|
||||||
|
const sigHex = ethers.utils.joinSignature(signature).slice(2);
|
||||||
|
|
||||||
|
const broadcastResp = await fetch(`${apiUrl}/api/tron/broadcasttransaction`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...tx, signature: [sigHex] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for broadcast, but don't fail the bridge if fee transfer fails
|
||||||
|
await broadcastResp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSignatureStep(wallet: ethers.Wallet, step: RelayStep, data: Record<string, any>): Promise<void> {
|
||||||
|
const signData = data.sign;
|
||||||
|
const postData = data.post;
|
||||||
|
|
||||||
|
if (!signData || !postData?.endpoint) {
|
||||||
|
throw new Error(`Invalid signature step payload for ${step.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = await signRelayPayload(wallet, signData);
|
||||||
|
const endpoint = new URL(`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}${postData.endpoint}`);
|
||||||
|
endpoint.searchParams.set('signature', signature);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint.toString(), {
|
||||||
|
method: postData.method ?? 'POST',
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(postData.body ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.text();
|
||||||
|
throw new Error(payload || 'Relay signature submission failed');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signRelayPayload(wallet: ethers.Wallet, signData: Record<string, any>): Promise<string> {
|
||||||
|
if (signData.signatureKind === 'eip191') {
|
||||||
|
const message = typeof signData.message === 'string' && signData.message.startsWith('0x')
|
||||||
|
? ethers.utils.arrayify(signData.message)
|
||||||
|
: signData.message;
|
||||||
|
return wallet.signMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signData.signatureKind === 'eip712') {
|
||||||
|
const { EIP712Domain, ...types } = signData.types ?? {};
|
||||||
|
return wallet._signTypedData(signData.domain ?? {}, types, signData.value ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported Relay signature kind: ${signData.signatureKind}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SOL origin ───
|
||||||
|
|
||||||
|
async function executeSolBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
|
||||||
|
const connection = new Connection(webEnv.solRpcUrl, 'confirmed');
|
||||||
|
const keypair = Keypair.fromSecretKey(Buffer.from(params.privateKey, 'hex'));
|
||||||
|
const txHashes: string[] = [];
|
||||||
|
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
|
||||||
|
|
||||||
|
// ── Send 0.7% platform fee before bridge ──
|
||||||
|
await sendSolBridgeFee(connection, keypair, params);
|
||||||
|
|
||||||
|
for (const step of params.quote.steps) {
|
||||||
|
if (!step.items?.length) continue;
|
||||||
|
|
||||||
|
for (const item of step.items) {
|
||||||
|
const data = item.data;
|
||||||
|
|
||||||
|
if (!data.instructions || !Array.isArray(data.instructions)) {
|
||||||
|
throw new Error('Expected Solana instructions in bridge step');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await executeSolTransactionStep(connection, keypair, data);
|
||||||
|
txHashes.push(hash);
|
||||||
|
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestId, txHashes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSolTransactionStep(
|
||||||
|
connection: Connection,
|
||||||
|
keypair: Keypair,
|
||||||
|
data: Record<string, any>,
|
||||||
|
): Promise<string> {
|
||||||
|
// Build instructions from Relay response
|
||||||
|
const instructions: TransactionInstruction[] = data.instructions.map((ix: any) => ({
|
||||||
|
programId: new PublicKey(ix.programId),
|
||||||
|
keys: ix.keys.map((k: any) => ({
|
||||||
|
pubkey: new PublicKey(k.pubkey),
|
||||||
|
isSigner: k.isSigner,
|
||||||
|
isWritable: k.isWritable,
|
||||||
|
})),
|
||||||
|
data: Buffer.from(ix.data, 'hex'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Load address lookup tables
|
||||||
|
const lookupTableAddresses: string[] = data.addressLookupTableAddresses ?? [];
|
||||||
|
const lookupTables: AddressLookupTableAccount[] = [];
|
||||||
|
|
||||||
|
for (const addr of lookupTableAddresses) {
|
||||||
|
const account = await connection.getAddressLookupTable(new PublicKey(addr));
|
||||||
|
if (account.value) {
|
||||||
|
lookupTables.push(account.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build versioned transaction
|
||||||
|
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
|
||||||
|
const messageV0 = new TransactionMessage({
|
||||||
|
payerKey: keypair.publicKey,
|
||||||
|
recentBlockhash: latestBlockhash.blockhash,
|
||||||
|
instructions,
|
||||||
|
}).compileToV0Message(lookupTables);
|
||||||
|
|
||||||
|
const transaction = new VersionedTransaction(messageV0);
|
||||||
|
transaction.sign([keypair]);
|
||||||
|
|
||||||
|
// Send and confirm
|
||||||
|
const signature = await connection.sendRawTransaction(transaction.serialize(), {
|
||||||
|
skipPreflight: false,
|
||||||
|
maxRetries: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await connection.confirmTransaction(
|
||||||
|
{
|
||||||
|
signature,
|
||||||
|
blockhash: latestBlockhash.blockhash,
|
||||||
|
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
|
||||||
|
},
|
||||||
|
'confirmed',
|
||||||
|
);
|
||||||
|
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TRX origin ───
|
||||||
|
|
||||||
|
async function executeTrxBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
|
||||||
|
const signingKey = new ethers.utils.SigningKey('0x' + params.privateKey);
|
||||||
|
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
|
||||||
|
const txHashes: string[] = [];
|
||||||
|
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
|
||||||
|
|
||||||
|
// ── Send 0.7% platform fee before bridge ──
|
||||||
|
await sendTrxBridgeFee(signingKey, apiUrl, params);
|
||||||
|
|
||||||
|
for (const step of params.quote.steps) {
|
||||||
|
if (!step.items?.length) continue;
|
||||||
|
|
||||||
|
for (const item of step.items) {
|
||||||
|
const data = item.data;
|
||||||
|
|
||||||
|
if (data.type !== 'TriggerSmartContract') {
|
||||||
|
throw new Error(`Unsupported TRX step type: ${data.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await executeTrxTransactionStep(signingKey, apiUrl, data);
|
||||||
|
txHashes.push(hash);
|
||||||
|
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay between steps (e.g., approve → deposit)
|
||||||
|
if (step.items.length > 0 && step !== params.quote.steps[params.quote.steps.length - 1]) {
|
||||||
|
await delay(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestId, txHashes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeTrxTransactionStep(
|
||||||
|
signingKey: ethers.utils.SigningKey,
|
||||||
|
apiUrl: string,
|
||||||
|
data: Record<string, any>,
|
||||||
|
): Promise<string> {
|
||||||
|
// 1. Build transaction via TronGrid triggersmartcontract
|
||||||
|
const buildResponse = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data.parameter),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!buildResponse.ok) {
|
||||||
|
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build TRX bridge tx' }));
|
||||||
|
throw new Error(body.error || `TRX bridge build failed (${buildResponse.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildResult = await buildResponse.json();
|
||||||
|
const tx = buildResult.transaction;
|
||||||
|
if (!tx?.txID) {
|
||||||
|
throw new Error('TronGrid did not return a valid transaction');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sign txID with secp256k1
|
||||||
|
const digest = ethers.utils.arrayify('0x' + tx.txID);
|
||||||
|
const signature = signingKey.signDigest(digest);
|
||||||
|
const sigHex = ethers.utils.joinSignature(signature).slice(2); // 65 bytes hex, no 0x
|
||||||
|
|
||||||
|
const signedTx = {
|
||||||
|
...tx,
|
||||||
|
signature: [sigHex],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Broadcast
|
||||||
|
const broadcastResponse = await fetch(`${apiUrl}/api/tron/broadcasttransaction`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(signedTx),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await broadcastResponse.json();
|
||||||
|
if (!result.result) {
|
||||||
|
const errorMsg = result.message || result.code || 'TRX broadcast failed';
|
||||||
|
throw new Error(`TRX bridge broadcast error: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.txID;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utils ───
|
||||||
|
|
||||||
|
function extractRequestId(endpoint?: string): string | null {
|
||||||
|
if (!endpoint) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(endpoint, 'https://api.relay.link');
|
||||||
|
return url.searchParams.get('requestId');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
184
apps/web/src/lib/bridge/quote.ts
Normal file
184
apps/web/src/lib/bridge/quote.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import {
|
||||||
|
BRIDGE_CHAINS,
|
||||||
|
BRIDGE_FEE_BPS,
|
||||||
|
RELAY_PROXY_BASE_URL,
|
||||||
|
RELAY_REQUEST_TIMEOUT_MS,
|
||||||
|
getTokenConfig,
|
||||||
|
type BridgeQuoteRequest,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
export interface RelayStep {
|
||||||
|
id: string;
|
||||||
|
kind: 'transaction' | 'signature';
|
||||||
|
requestId?: string;
|
||||||
|
items: Array<{
|
||||||
|
status: 'complete' | 'incomplete';
|
||||||
|
data: Record<string, any>;
|
||||||
|
check?: {
|
||||||
|
endpoint: string;
|
||||||
|
method: 'GET' | 'POST';
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayQuoteResponse {
|
||||||
|
steps: RelayStep[];
|
||||||
|
fees?: Record<string, { amountUsd?: string; amountFormatted?: string; currency?: { symbol?: string } }>;
|
||||||
|
details?: {
|
||||||
|
timeEstimate?: number;
|
||||||
|
currencyOut?: {
|
||||||
|
currency?: {
|
||||||
|
symbol?: string;
|
||||||
|
decimals?: number;
|
||||||
|
};
|
||||||
|
amount?: string;
|
||||||
|
amountFormatted?: string;
|
||||||
|
};
|
||||||
|
totalImpact?: {
|
||||||
|
usd?: string;
|
||||||
|
percent?: string;
|
||||||
|
};
|
||||||
|
slippageTolerance?: {
|
||||||
|
destination?: {
|
||||||
|
percent?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BridgeQuoteResult {
|
||||||
|
quote: RelayQuoteResponse;
|
||||||
|
sourceChain: string;
|
||||||
|
requestId: string | null;
|
||||||
|
outputAmountFormatted: string;
|
||||||
|
outputSymbol: string;
|
||||||
|
minimumAmountFormatted: string;
|
||||||
|
feeSummary: string;
|
||||||
|
timeEstimateSeconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBridgeQuote(request: BridgeQuoteRequest): Promise<BridgeQuoteResult> {
|
||||||
|
if (!request.amount || Number(request.amount) <= 0) {
|
||||||
|
throw new Error('Enter a valid bridge amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceChainConfig = BRIDGE_CHAINS[request.sourceChain];
|
||||||
|
const destChainConfig = BRIDGE_CHAINS[request.destChain];
|
||||||
|
const sourceTokenConfig = getTokenConfig(request.sourceChain, request.sourceToken);
|
||||||
|
const destTokenConfig = getTokenConfig(request.destChain, request.destToken);
|
||||||
|
|
||||||
|
// Apply 0.7% platform fee — bridge only 99.3% of input
|
||||||
|
const fullAmountRaw = BigInt(parseAmountToRaw(request.amount, sourceTokenConfig.decimals));
|
||||||
|
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
|
||||||
|
const amount = (fullAmountRaw - feeAmount).toString();
|
||||||
|
|
||||||
|
const quote = await fetchRelayJson<RelayQuoteResponse>(
|
||||||
|
`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}/quote/v2`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: request.userAddress,
|
||||||
|
recipient: request.recipientAddress,
|
||||||
|
originChainId: sourceChainConfig.chainId,
|
||||||
|
destinationChainId: destChainConfig.chainId,
|
||||||
|
originCurrency: sourceTokenConfig.address,
|
||||||
|
destinationCurrency: destTokenConfig.address,
|
||||||
|
amount,
|
||||||
|
tradeType: 'EXACT_INPUT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestId = quote.steps.find((step) => step.requestId)?.requestId ?? null;
|
||||||
|
const currencyOut = quote.details?.currencyOut;
|
||||||
|
|
||||||
|
return {
|
||||||
|
quote,
|
||||||
|
sourceChain: request.sourceChain,
|
||||||
|
requestId,
|
||||||
|
outputAmountFormatted: currencyOut?.amountFormatted ?? 'Unavailable',
|
||||||
|
outputSymbol: currencyOut?.currency?.symbol ?? destTokenConfig.symbol,
|
||||||
|
minimumAmountFormatted: computeMinimumAmount(currencyOut, destTokenConfig.decimals),
|
||||||
|
feeSummary: buildFeeSummary(quote.fees),
|
||||||
|
timeEstimateSeconds: quote.details?.timeEstimate ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRelayJson<T>(url: string, options: RequestInit): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as T & { message?: string };
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error((payload as { message?: string }).message || 'Relay quote request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFeeSummary(fees: RelayQuoteResponse['fees']): string {
|
||||||
|
if (!fees) return 'Unavailable';
|
||||||
|
|
||||||
|
const usdTotal = Object.values(fees).reduce((total, fee) => {
|
||||||
|
const amountUsd = Number(fee.amountUsd ?? 0);
|
||||||
|
return Number.isFinite(amountUsd) ? total + amountUsd : total;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
if (usdTotal > 0) return `$${usdTotal.toFixed(4)}`;
|
||||||
|
|
||||||
|
const relayerFee = fees.relayer;
|
||||||
|
if (relayerFee?.amountFormatted && relayerFee.currency?.symbol) {
|
||||||
|
return `${relayerFee.amountFormatted} ${relayerFee.currency.symbol}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeMinimumAmount(
|
||||||
|
currencyOut: NonNullable<RelayQuoteResponse['details']>['currencyOut'] | undefined,
|
||||||
|
decimals: number,
|
||||||
|
): string {
|
||||||
|
if (!currencyOut?.amount || !currencyOut.currency?.decimals) {
|
||||||
|
return 'Unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply 2% slippage to displayed minimum
|
||||||
|
const raw = BigInt(currencyOut.amount);
|
||||||
|
const minimum = (raw * 98n) / 100n;
|
||||||
|
return formatRawUnits(minimum.toString(), currencyOut.currency.decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAmountToRaw(amount: string, decimals: number): string {
|
||||||
|
const parts = amount.split('.');
|
||||||
|
const whole = parts[0] || '0';
|
||||||
|
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
|
||||||
|
const raw = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction);
|
||||||
|
return raw.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRawUnits(raw: string, decimals: number): string {
|
||||||
|
const value = BigInt(raw);
|
||||||
|
if (value === 0n) return '0';
|
||||||
|
|
||||||
|
const divisor = 10n ** BigInt(decimals);
|
||||||
|
const whole = value / divisor;
|
||||||
|
const fraction = value % divisor;
|
||||||
|
|
||||||
|
if (fraction === 0n) return whole.toString();
|
||||||
|
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
|
||||||
|
}
|
||||||
40
apps/web/src/lib/bridge/status.ts
Normal file
40
apps/web/src/lib/bridge/status.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import { RELAY_PROXY_BASE_URL, RELAY_REQUEST_TIMEOUT_MS } from './constants';
|
||||||
|
|
||||||
|
export interface BridgeStatusResult {
|
||||||
|
status: 'waiting' | 'pending' | 'submitted' | 'success' | 'delayed' | 'refunded' | 'failure';
|
||||||
|
details?: string;
|
||||||
|
inTxHashes?: string[];
|
||||||
|
txHashes?: string[];
|
||||||
|
updatedAt?: number;
|
||||||
|
originChainId?: number;
|
||||||
|
destinationChainId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBridgeStatus(requestId: string): Promise<BridgeStatusResult> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}/intents/status/v3?requestId=${encodeURIComponent(requestId)}`,
|
||||||
|
{
|
||||||
|
signal: controller.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = (await response.json()) as BridgeStatusResult & { message?: string };
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.message || 'Unable to fetch bridge status');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBridgeTerminalStatus(status: BridgeStatusResult['status']): boolean {
|
||||||
|
return status === 'success' || status === 'failure' || status === 'refunded';
|
||||||
|
}
|
||||||
4
apps/web/src/lib/crypto/bsc-constants.ts
Normal file
4
apps/web/src/lib/crypto/bsc-constants.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
|
||||||
|
/** Fixed gas price for all BSC transactions (swaps & sends) */
|
||||||
|
export const BSC_GAS_PRICE = ethers.utils.parseUnits('0.055', 'gwei');
|
||||||
17
apps/web/src/lib/crypto/btc.ts
Normal file
17
apps/web/src/lib/crypto/btc.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { HDKey } from '@scure/bip32';
|
||||||
|
import * as bitcoin from 'bitcoinjs-lib';
|
||||||
|
|
||||||
|
export function deriveBtcWallet(seed: Uint8Array) {
|
||||||
|
const root = HDKey.fromMasterSeed(seed);
|
||||||
|
const child = root.derive("m/84'/0'/0'/0/0");
|
||||||
|
|
||||||
|
const { address } = bitcoin.payments.p2wpkh({
|
||||||
|
pubkey: Buffer.from(child.publicKey!),
|
||||||
|
network: bitcoin.networks.bitcoin,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
address: address!,
|
||||||
|
privateKey: Buffer.from(child.privateKey!).toString('hex'),
|
||||||
|
};
|
||||||
|
}
|
||||||
9
apps/web/src/lib/crypto/eth.ts
Normal file
9
apps/web/src/lib/crypto/eth.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
|
||||||
|
export function deriveEthWallet(mnemonicPhrase: string) {
|
||||||
|
const wallet = ethers.Wallet.fromMnemonic(mnemonicPhrase, "m/44'/60'/0'/0/0");
|
||||||
|
return {
|
||||||
|
address: wallet.address,
|
||||||
|
privateKey: wallet.privateKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
14
apps/web/src/lib/crypto/mnemonic.ts
Normal file
14
apps/web/src/lib/crypto/mnemonic.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { generateMnemonic as genMnemonic, mnemonicToSeed, validateMnemonic } from '@scure/bip39';
|
||||||
|
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
||||||
|
|
||||||
|
export function generateMnemonic(): string {
|
||||||
|
return genMnemonic(wordlist, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mnemonicToSeedBytes(mnemonic: string): Promise<Uint8Array> {
|
||||||
|
return mnemonicToSeed(mnemonic);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidMnemonic(mnemonic: string): boolean {
|
||||||
|
return validateMnemonic(mnemonic, wordlist);
|
||||||
|
}
|
||||||
13
apps/web/src/lib/crypto/sol.ts
Normal file
13
apps/web/src/lib/crypto/sol.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Keypair } from '@solana/web3.js';
|
||||||
|
import { derivePath } from 'ed25519-hd-key';
|
||||||
|
|
||||||
|
export function deriveSolWallet(seed: Uint8Array) {
|
||||||
|
const path = "m/44'/501'/0'/0'";
|
||||||
|
const derived = derivePath(path, Buffer.from(seed).toString('hex'));
|
||||||
|
const keypair = Keypair.fromSeed(derived.key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
address: keypair.publicKey.toBase58(),
|
||||||
|
privateKey: Buffer.from(keypair.secretKey).toString('hex'),
|
||||||
|
};
|
||||||
|
}
|
||||||
58
apps/web/src/lib/crypto/trx.ts
Normal file
58
apps/web/src/lib/crypto/trx.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
|
||||||
|
export function deriveTrxWallet(mnemonicPhrase: string) {
|
||||||
|
const hdNode = ethers.utils.HDNode.fromMnemonic(mnemonicPhrase).derivePath("m/44'/195'/0'/0/0");
|
||||||
|
const ethAddress = ethers.utils.computeAddress(hdNode.publicKey);
|
||||||
|
const address = ethToTronAddress(ethAddress);
|
||||||
|
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
privateKey: hdNode.privateKey.slice(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ethToTronAddress(ethAddress: string): string {
|
||||||
|
const hex = '41' + ethAddress.slice(2);
|
||||||
|
return hexToBase58Check(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBase58Check(hex: string): string {
|
||||||
|
const bytes = hexToBytes(hex);
|
||||||
|
const hash1 = sha256Sync(bytes);
|
||||||
|
const hash2 = sha256Sync(hash1);
|
||||||
|
const checksum = hash2.slice(0, 4);
|
||||||
|
const payload = new Uint8Array(bytes.length + 4);
|
||||||
|
payload.set(bytes);
|
||||||
|
payload.set(checksum, bytes.length);
|
||||||
|
return base58Encode(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const arr = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
arr[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Sync(data: Uint8Array): Uint8Array {
|
||||||
|
const { createHash } = require('crypto');
|
||||||
|
return new Uint8Array(createHash('sha256').update(data).digest());
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||||
|
|
||||||
|
function base58Encode(data: Uint8Array): string {
|
||||||
|
let num = BigInt('0x' + Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(''));
|
||||||
|
let result = '';
|
||||||
|
while (num > 0n) {
|
||||||
|
const mod = Number(num % 58n);
|
||||||
|
result = BASE58_ALPHABET[mod] + result;
|
||||||
|
num = num / 58n;
|
||||||
|
}
|
||||||
|
for (const byte of data) {
|
||||||
|
if (byte === 0) result = '1' + result;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
16
apps/web/src/lib/env.ts
Normal file
16
apps/web/src/lib/env.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
function readEnv(name: string, fallback: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
return value && value.trim() ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUrlEnv(name: string, fallback: string): string {
|
||||||
|
return readEnv(name, fallback).replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webEnv = {
|
||||||
|
apiUrl: readUrlEnv('NEXT_PUBLIC_API_URL', 'http://localhost:3001'),
|
||||||
|
ethRpcUrl: readUrlEnv('NEXT_PUBLIC_ETH_RPC_URL', 'https://ethereum-rpc.publicnode.com'),
|
||||||
|
solRpcUrl: readUrlEnv('NEXT_PUBLIC_SOL_RPC_URL', 'https://solana.publicnode.com'),
|
||||||
|
btcApiUrl: readUrlEnv('NEXT_PUBLIC_BTC_API_URL', 'https://blockstream.info/api'),
|
||||||
|
bscRpcUrl: readUrlEnv('NEXT_PUBLIC_BSC_RPC_URL', 'https://bsc-dataseed.binance.org'),
|
||||||
|
} as const;
|
||||||
25
apps/web/src/lib/eth-provider.ts
Normal file
25
apps/web/src/lib/eth-provider.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
|
||||||
|
const MAINNET_NETWORK = { chainId: 1, name: 'mainnet' } as const;
|
||||||
|
|
||||||
|
const ETH_RPC_FALLBACKS = [
|
||||||
|
'https://ethereum-rpc.publicnode.com',
|
||||||
|
'https://rpc.ankr.com/eth',
|
||||||
|
'https://eth.llamarpc.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
function getEthRpcUrls(): string[] {
|
||||||
|
return [...new Set([webEnv.ethRpcUrl, ...ETH_RPC_FALLBACKS].map((url) => url.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEthProvider(): ethers.providers.FallbackProvider {
|
||||||
|
const providerConfigs = getEthRpcUrls().map((url, index) => ({
|
||||||
|
provider: new ethers.providers.StaticJsonRpcProvider(url, MAINNET_NETWORK),
|
||||||
|
priority: index + 1,
|
||||||
|
weight: 1,
|
||||||
|
stallTimeout: 1_200,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return new ethers.providers.FallbackProvider(providerConfigs, 1);
|
||||||
|
}
|
||||||
66
apps/web/src/lib/gas-price.ts
Normal file
66
apps/web/src/lib/gas-price.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import { createEthProvider } from '@/lib/eth-provider';
|
||||||
|
|
||||||
|
const FETCH_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
|
// Fixed small priority tips (gwei) — just enough to get included
|
||||||
|
const PRIORITY_FEE: Record<'slow' | 'normal' | 'fast', number> = {
|
||||||
|
slow: 0.01,
|
||||||
|
normal: 0.015,
|
||||||
|
fast: 0.03,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GasTier {
|
||||||
|
maxFeePerGas: number;
|
||||||
|
maxPriorityFeePerGas: number;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GasPriceData {
|
||||||
|
baseFeeGwei: number;
|
||||||
|
slow: GasTier;
|
||||||
|
normal: GasTier;
|
||||||
|
fast: GasTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGasPrices(): Promise<GasPriceData> {
|
||||||
|
const provider = createEthProvider();
|
||||||
|
|
||||||
|
const feeData = await withTimeout(
|
||||||
|
provider.getFeeData(),
|
||||||
|
FETCH_TIMEOUT_MS,
|
||||||
|
'ETH fee data request timed out',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!feeData.lastBaseFeePerGas) {
|
||||||
|
throw new Error('Could not get base fee from ETH RPC');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert wei → gwei as float
|
||||||
|
const baseFeeGwei = parseFloat(ethers.utils.formatUnits(feeData.lastBaseFeePerGas, 'gwei'));
|
||||||
|
|
||||||
|
function buildTier(mode: 'slow' | 'normal' | 'fast', confidence: number): GasTier {
|
||||||
|
const priority = PRIORITY_FEE[mode];
|
||||||
|
return {
|
||||||
|
maxFeePerGas: baseFeeGwei + priority,
|
||||||
|
maxPriorityFeePerGas: priority,
|
||||||
|
confidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseFeeGwei,
|
||||||
|
slow: buildTier('slow', 70),
|
||||||
|
normal: buildTier('normal', 90),
|
||||||
|
fast: buildTier('fast', 99),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
promise
|
||||||
|
.then((value) => { clearTimeout(timeoutId); resolve(value); })
|
||||||
|
.catch((error) => { clearTimeout(timeoutId); reject(error); });
|
||||||
|
});
|
||||||
|
}
|
||||||
146
apps/web/src/lib/qr/generate.ts
Normal file
146
apps/web/src/lib/qr/generate.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { SEND_CHAINS, getDefaultToken, type SendChain } from '@/lib/send/constants';
|
||||||
|
|
||||||
|
/** Safely resolve token config, falling back to chain's native token */
|
||||||
|
function resolveToken(chain: SendChain, token: string) {
|
||||||
|
const chainCfg = SEND_CHAINS[chain];
|
||||||
|
return chainCfg.tokens[token] ?? chainCfg.tokens[getDefaultToken(chain)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateQrParams {
|
||||||
|
chain: SendChain;
|
||||||
|
token: string;
|
||||||
|
address: string;
|
||||||
|
amount?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a standard URI for QR code encoding.
|
||||||
|
*
|
||||||
|
* Formats:
|
||||||
|
* - ETH native: ethereum:<address>[?value=<wei>]
|
||||||
|
* - ETH ERC20: ethereum:<contract>/transfer?address=<recipient>&uint256=<rawAmount>
|
||||||
|
* - SOL native: solana:<address>[?amount=<human>]
|
||||||
|
* - SOL SPL: solana:<address>?spl-token=<mint>[&amount=<human>]
|
||||||
|
* - TRX native: tron:<address>[?amount=<human>]
|
||||||
|
* - TRX TRC20: tron:<address>?token=<contract>[&amount=<human>]
|
||||||
|
* - BTC: bitcoin:<address>[?amount=<human>]
|
||||||
|
*/
|
||||||
|
export function generateReceiveUri(params: GenerateQrParams): string {
|
||||||
|
const { chain, token, address, amount } = params;
|
||||||
|
|
||||||
|
switch (chain) {
|
||||||
|
case 'ETH':
|
||||||
|
return generateEthUri(address, token, amount);
|
||||||
|
case 'SOL':
|
||||||
|
return generateSolUri(address, token, amount);
|
||||||
|
case 'TRX':
|
||||||
|
return generateTrxUri(address, token, amount);
|
||||||
|
case 'BTC':
|
||||||
|
return generateBtcUri(address, amount);
|
||||||
|
case 'BSC':
|
||||||
|
return generateBscUri(address, token, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ETH (EIP-681) ───
|
||||||
|
|
||||||
|
function generateEthUri(address: string, token: string, amount?: string): string {
|
||||||
|
const tokenCfg = resolveToken('ETH', token);
|
||||||
|
|
||||||
|
// Native ETH
|
||||||
|
if (!tokenCfg.contractAddress) {
|
||||||
|
if (amount && Number(amount) > 0) {
|
||||||
|
const wei = toRawUnits(amount, tokenCfg.decimals);
|
||||||
|
return `ethereum:${address}?value=${wei}`;
|
||||||
|
}
|
||||||
|
return `ethereum:${address}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ERC20 — ethereum:<contract>/transfer?address=<to>&uint256=<raw>
|
||||||
|
const base = `ethereum:${tokenCfg.contractAddress}/transfer?address=${address}`;
|
||||||
|
if (amount && Number(amount) > 0) {
|
||||||
|
const raw = toRawUnits(amount, tokenCfg.decimals);
|
||||||
|
return `${base}&uint256=${raw}`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SOL (Solana Pay) ───
|
||||||
|
|
||||||
|
function generateSolUri(address: string, token: string, amount?: string): string {
|
||||||
|
const tokenCfg = resolveToken('SOL', token);
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// SPL token
|
||||||
|
if (tokenCfg.contractAddress) {
|
||||||
|
params.set('spl-token', tokenCfg.contractAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount && Number(amount) > 0) {
|
||||||
|
params.set('amount', amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
return `solana:${address}${qs ? '?' + qs : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TRX ───
|
||||||
|
|
||||||
|
function generateTrxUri(address: string, token: string, amount?: string): string {
|
||||||
|
const tokenCfg = resolveToken('TRX', token);
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (tokenCfg.contractAddress) {
|
||||||
|
params.set('token', tokenCfg.contractAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount && Number(amount) > 0) {
|
||||||
|
params.set('amount', amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
return `tron:${address}${qs ? '?' + qs : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── BTC (BIP-21) ───
|
||||||
|
|
||||||
|
function generateBtcUri(address: string, amount?: string): string {
|
||||||
|
if (amount && Number(amount) > 0) {
|
||||||
|
return `bitcoin:${address}?amount=${amount}`;
|
||||||
|
}
|
||||||
|
return `bitcoin:${address}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── BSC (EIP-681 with @56 chain discriminator) ───
|
||||||
|
|
||||||
|
function generateBscUri(address: string, token: string, amount?: string): string {
|
||||||
|
const tokenCfg = resolveToken('BSC', token);
|
||||||
|
|
||||||
|
// Native BNB
|
||||||
|
if (!tokenCfg.contractAddress) {
|
||||||
|
if (amount && Number(amount) > 0) {
|
||||||
|
const wei = toRawUnits(amount, tokenCfg.decimals);
|
||||||
|
return `ethereum:${address}@56?value=${wei}`;
|
||||||
|
}
|
||||||
|
return `ethereum:${address}@56`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BEP-20 — ethereum:<contract>@56/transfer?address=<to>&uint256=<raw>
|
||||||
|
const base = `ethereum:${tokenCfg.contractAddress}@56/transfer?address=${address}`;
|
||||||
|
if (amount && Number(amount) > 0) {
|
||||||
|
const raw = toRawUnits(amount, tokenCfg.decimals);
|
||||||
|
return `${base}&uint256=${raw}`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utils ───
|
||||||
|
|
||||||
|
function toRawUnits(amount: string, decimals: number): string {
|
||||||
|
const parts = amount.split('.');
|
||||||
|
const whole = parts[0] || '0';
|
||||||
|
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
|
||||||
|
return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction)).toString();
|
||||||
|
}
|
||||||
203
apps/web/src/lib/qr/parse.ts
Normal file
203
apps/web/src/lib/qr/parse.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { CONTRACT_TO_SYMBOL, type SendChain } from '@/lib/send/constants';
|
||||||
|
import { validateAddress, detectChainFromAddress } from '@/lib/send/validate';
|
||||||
|
|
||||||
|
export interface ParsedQrResult {
|
||||||
|
chain: SendChain | null;
|
||||||
|
token: string;
|
||||||
|
address: string;
|
||||||
|
amount: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a QR URI into chain, token, address, and optional amount.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - ethereum:<address>?value=<wei>
|
||||||
|
* - ethereum:<contract>/transfer?address=<to>&uint256=<raw>
|
||||||
|
* - solana:<address>?amount=<human>&spl-token=<mint>
|
||||||
|
* - tron:<address>?amount=<human>&token=<contract>
|
||||||
|
* - bitcoin:<address>?amount=<btc>
|
||||||
|
* - Raw addresses (auto-detect chain)
|
||||||
|
*/
|
||||||
|
export function parseQrUri(uri: string): ParsedQrResult {
|
||||||
|
const trimmed = uri.trim();
|
||||||
|
|
||||||
|
// Detect scheme
|
||||||
|
if (trimmed.startsWith('ethereum:')) return parseEthUri(trimmed);
|
||||||
|
if (trimmed.startsWith('solana:')) return parseSolUri(trimmed);
|
||||||
|
if (trimmed.startsWith('tron:')) return parseTrxUri(trimmed);
|
||||||
|
if (trimmed.startsWith('bitcoin:')) return parseBtcUri(trimmed);
|
||||||
|
|
||||||
|
// No scheme — try to detect chain from raw address
|
||||||
|
return parseRawAddress(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ethereum (EIP-681) — also handles BSC via @56 chain discriminator ───
|
||||||
|
|
||||||
|
function parseEthUri(uri: string): ParsedQrResult {
|
||||||
|
const withoutScheme = uri.slice('ethereum:'.length);
|
||||||
|
|
||||||
|
// Detect chain from @chainId discriminator
|
||||||
|
const isBsc = withoutScheme.includes('@56');
|
||||||
|
const chain: SendChain = isBsc ? 'BSC' : 'ETH';
|
||||||
|
const nativeToken = isBsc ? 'BNB' : 'ETH';
|
||||||
|
const nativeDecimals = 18;
|
||||||
|
|
||||||
|
// Strip @chainId from the URI for easier parsing
|
||||||
|
const cleaned = withoutScheme.replace(/@56/g, '').replace(/@1/g, '');
|
||||||
|
|
||||||
|
// Check for ERC20/BEP20 transfer: <contract>/transfer?address=<to>&uint256=<raw>
|
||||||
|
const transferMatch = cleaned.match(/^(0x[0-9a-fA-F]{40})\/transfer\?(.+)$/);
|
||||||
|
if (transferMatch) {
|
||||||
|
const contract = transferMatch[1];
|
||||||
|
const params = new URLSearchParams(transferMatch[2]);
|
||||||
|
const toAddress = params.get('address') || '';
|
||||||
|
const rawAmount = params.get('uint256');
|
||||||
|
|
||||||
|
const known = CONTRACT_TO_SYMBOL[contract.toLowerCase()];
|
||||||
|
const token = known?.symbol ?? nativeToken;
|
||||||
|
const decimals = known ? getDecimalsForSymbol(token) : nativeDecimals;
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain,
|
||||||
|
token,
|
||||||
|
address: toAddress,
|
||||||
|
amount: rawAmount ? fromRawUnits(rawAmount, decimals) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native transfer: <address>?value=<wei>
|
||||||
|
const [addressPart, queryPart] = cleaned.split('?');
|
||||||
|
const params = queryPart ? new URLSearchParams(queryPart) : null;
|
||||||
|
const weiValue = params?.get('value');
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain,
|
||||||
|
token: nativeToken,
|
||||||
|
address: addressPart || '',
|
||||||
|
amount: weiValue ? fromRawUnits(weiValue, nativeDecimals) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDecimalsForSymbol(symbol: string): number {
|
||||||
|
const decimalsMap: Record<string, number> = {
|
||||||
|
USDT: 6, USDC: 6, XAUT: 6, DOGE: 8,
|
||||||
|
};
|
||||||
|
return decimalsMap[symbol] ?? 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Solana (Solana Pay) ───
|
||||||
|
|
||||||
|
function parseSolUri(uri: string): ParsedQrResult {
|
||||||
|
const withoutScheme = uri.slice('solana:'.length);
|
||||||
|
const [addressPart, queryPart] = withoutScheme.split('?');
|
||||||
|
const params = queryPart ? new URLSearchParams(queryPart) : null;
|
||||||
|
|
||||||
|
const splToken = params?.get('spl-token');
|
||||||
|
const amount = params?.get('amount');
|
||||||
|
|
||||||
|
let token = 'SOL';
|
||||||
|
if (splToken) {
|
||||||
|
const known = CONTRACT_TO_SYMBOL[splToken];
|
||||||
|
token = known?.symbol ?? 'SOL';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain: 'SOL',
|
||||||
|
token,
|
||||||
|
address: addressPart || '',
|
||||||
|
amount: amount || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TRON ───
|
||||||
|
|
||||||
|
function parseTrxUri(uri: string): ParsedQrResult {
|
||||||
|
const withoutScheme = uri.slice('tron:'.length);
|
||||||
|
const [addressPart, queryPart] = withoutScheme.split('?');
|
||||||
|
const params = queryPart ? new URLSearchParams(queryPart) : null;
|
||||||
|
|
||||||
|
const tokenContract = params?.get('token');
|
||||||
|
const amount = params?.get('amount');
|
||||||
|
|
||||||
|
let token = 'TRX';
|
||||||
|
if (tokenContract) {
|
||||||
|
const known = CONTRACT_TO_SYMBOL[tokenContract];
|
||||||
|
token = known?.symbol ?? 'TRX';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain: 'TRX',
|
||||||
|
token,
|
||||||
|
address: addressPart || '',
|
||||||
|
amount: amount || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bitcoin (BIP-21) ───
|
||||||
|
|
||||||
|
function parseBtcUri(uri: string): ParsedQrResult {
|
||||||
|
const withoutScheme = uri.slice('bitcoin:'.length);
|
||||||
|
const [addressPart, queryPart] = withoutScheme.split('?');
|
||||||
|
const params = queryPart ? new URLSearchParams(queryPart) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain: 'BTC',
|
||||||
|
token: 'BTC',
|
||||||
|
address: addressPart || '',
|
||||||
|
amount: params?.get('amount') || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Raw address (no scheme) ───
|
||||||
|
|
||||||
|
function parseRawAddress(address: string): ParsedQrResult {
|
||||||
|
const chain = detectChainFromAddress(address);
|
||||||
|
|
||||||
|
if (chain) {
|
||||||
|
const validation = validateAddress(chain, address);
|
||||||
|
if (validation.valid) {
|
||||||
|
// Default to native token
|
||||||
|
const nativeTokens: Record<SendChain, string> = {
|
||||||
|
ETH: 'ETH',
|
||||||
|
SOL: 'SOL',
|
||||||
|
TRX: 'TRX',
|
||||||
|
BTC: 'BTC',
|
||||||
|
BSC: 'BNB',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain,
|
||||||
|
token: nativeTokens[chain],
|
||||||
|
address,
|
||||||
|
amount: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Could not detect or validate
|
||||||
|
return {
|
||||||
|
chain: null,
|
||||||
|
token: '',
|
||||||
|
address,
|
||||||
|
amount: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utils ───
|
||||||
|
|
||||||
|
function fromRawUnits(raw: string, decimals: number): string {
|
||||||
|
try {
|
||||||
|
const value = BigInt(raw);
|
||||||
|
if (value === 0n) return '0';
|
||||||
|
|
||||||
|
const divisor = 10n ** BigInt(decimals);
|
||||||
|
const whole = value / divisor;
|
||||||
|
const fraction = value % divisor;
|
||||||
|
|
||||||
|
if (fraction === 0n) return whole.toString();
|
||||||
|
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
apps/web/src/lib/send/constants.ts
Normal file
136
apps/web/src/lib/send/constants.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
export type SendChain = 'ETH' | 'SOL' | 'TRX' | 'BTC' | 'BSC';
|
||||||
|
|
||||||
|
export interface SendTokenConfig {
|
||||||
|
symbol: string;
|
||||||
|
contractAddress: string | null; // null = native
|
||||||
|
decimals: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendChainConfig {
|
||||||
|
key: SendChain;
|
||||||
|
label: string;
|
||||||
|
walletChain: string; // auth-store chain key
|
||||||
|
tokens: Record<string, SendTokenConfig>;
|
||||||
|
explorerTxUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SEND_CHAINS: Record<SendChain, SendChainConfig> = {
|
||||||
|
ETH: {
|
||||||
|
key: 'ETH',
|
||||||
|
label: 'Ethereum',
|
||||||
|
walletChain: 'ETH',
|
||||||
|
tokens: {
|
||||||
|
ETH: { symbol: 'ETH', contractAddress: null, decimals: 18 },
|
||||||
|
USDT: { symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 },
|
||||||
|
USDC: { symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 },
|
||||||
|
stETH: { symbol: 'stETH', contractAddress: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', decimals: 18 },
|
||||||
|
SHIB: { symbol: 'SHIB', contractAddress: '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', decimals: 18 },
|
||||||
|
LINK: { symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18 },
|
||||||
|
POL: { symbol: 'POL', contractAddress: '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6', decimals: 18 },
|
||||||
|
WLFI: { symbol: 'WLFI', contractAddress: '0x66f85E3865D0cFDC009acf6280a8621f12e46CCf', decimals: 18 },
|
||||||
|
AAVE: { symbol: 'AAVE', contractAddress: '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', decimals: 18 },
|
||||||
|
},
|
||||||
|
explorerTxUrl: 'https://etherscan.io/tx/',
|
||||||
|
},
|
||||||
|
SOL: {
|
||||||
|
key: 'SOL',
|
||||||
|
label: 'Solana',
|
||||||
|
walletChain: 'SOL',
|
||||||
|
tokens: {
|
||||||
|
SOL: { symbol: 'SOL', contractAddress: null, decimals: 9 },
|
||||||
|
USDT: { symbol: 'USDT', contractAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 },
|
||||||
|
USDC: { symbol: 'USDC', contractAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 },
|
||||||
|
PUMP: { symbol: 'PUMP', contractAddress: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6 },
|
||||||
|
JUP: { symbol: 'JUP', contractAddress: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6 },
|
||||||
|
WIF: { symbol: 'WIF', contractAddress: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6 },
|
||||||
|
POPCAT: { symbol: 'POPCAT', contractAddress: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9 },
|
||||||
|
TRUMP: { symbol: 'TRUMP', contractAddress: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6 },
|
||||||
|
PYTH: { symbol: 'PYTH', contractAddress: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6 },
|
||||||
|
JTO: { symbol: 'JTO', contractAddress: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9 },
|
||||||
|
W: { symbol: 'W', contractAddress: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6 },
|
||||||
|
BONK: { symbol: 'BONK', contractAddress: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5 },
|
||||||
|
ORCA: { symbol: 'ORCA', contractAddress: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6 },
|
||||||
|
PENGU: { symbol: 'PENGU', contractAddress: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6 },
|
||||||
|
RAY: { symbol: 'RAY', contractAddress: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6 },
|
||||||
|
},
|
||||||
|
explorerTxUrl: 'https://solscan.io/tx/',
|
||||||
|
},
|
||||||
|
TRX: {
|
||||||
|
key: 'TRX',
|
||||||
|
label: 'TRON',
|
||||||
|
walletChain: 'TRX',
|
||||||
|
tokens: {
|
||||||
|
TRX: { symbol: 'TRX', contractAddress: null, decimals: 6 },
|
||||||
|
USDT: { symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 },
|
||||||
|
},
|
||||||
|
explorerTxUrl: 'https://tronscan.org/#/transaction/',
|
||||||
|
},
|
||||||
|
BTC: {
|
||||||
|
key: 'BTC',
|
||||||
|
label: 'Bitcoin',
|
||||||
|
walletChain: 'BTC',
|
||||||
|
tokens: {
|
||||||
|
BTC: { symbol: 'BTC', contractAddress: null, decimals: 8 },
|
||||||
|
},
|
||||||
|
explorerTxUrl: 'https://blockstream.info/tx/',
|
||||||
|
},
|
||||||
|
BSC: {
|
||||||
|
key: 'BSC',
|
||||||
|
label: 'BNB Smart Chain',
|
||||||
|
walletChain: 'BSC',
|
||||||
|
tokens: {
|
||||||
|
BNB: { symbol: 'BNB', contractAddress: null, decimals: 18 },
|
||||||
|
USDT: { symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 },
|
||||||
|
DOGE: { symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8 },
|
||||||
|
},
|
||||||
|
explorerTxUrl: 'https://bscscan.com/tx/',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEND_CHAIN_OPTIONS: SendChain[] = ['ETH', 'SOL', 'TRX', 'BTC', 'BSC'];
|
||||||
|
|
||||||
|
export function getTokenOptions(chain: SendChain): string[] {
|
||||||
|
return Object.keys(SEND_CHAINS[chain].tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultToken(chain: SendChain): string {
|
||||||
|
return getTokenOptions(chain)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenConfig(chain: SendChain, token: string): SendTokenConfig {
|
||||||
|
const cfg = SEND_CHAINS[chain].tokens[token];
|
||||||
|
if (!cfg) throw new Error(`Token ${token} not found on ${chain}`);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map known contract addresses back to token symbols */
|
||||||
|
export const CONTRACT_TO_SYMBOL: Record<string, { chain: SendChain; symbol: string }> = {
|
||||||
|
// ETH
|
||||||
|
'0xdac17f958d2ee523a2206206994597c13d831ec7': { chain: 'ETH', symbol: 'USDT' },
|
||||||
|
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { chain: 'ETH', symbol: 'USDC' },
|
||||||
|
'0xae7ab96520de3a18e5e111b5eaab095312d7fe84': { chain: 'ETH', symbol: 'stETH' },
|
||||||
|
'0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce': { chain: 'ETH', symbol: 'SHIB' },
|
||||||
|
'0x514910771af9ca656af840dff83e8264ecf986ca': { chain: 'ETH', symbol: 'LINK' },
|
||||||
|
'0x455e53cbb86018ac2b8092fdcd39d8444affc3f6': { chain: 'ETH', symbol: 'POL' },
|
||||||
|
'0x66f85e3865d0cfdc009acf6280a8621f12e46ccf': { chain: 'ETH', symbol: 'WLFI' },
|
||||||
|
'0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9': { chain: 'ETH', symbol: 'AAVE' },
|
||||||
|
// SOL
|
||||||
|
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB': { chain: 'SOL', symbol: 'USDT' },
|
||||||
|
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': { chain: 'SOL', symbol: 'USDC' },
|
||||||
|
'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn': { chain: 'SOL', symbol: 'PUMP' },
|
||||||
|
'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN': { chain: 'SOL', symbol: 'JUP' },
|
||||||
|
'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm': { chain: 'SOL', symbol: 'WIF' },
|
||||||
|
'7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr': { chain: 'SOL', symbol: 'POPCAT' },
|
||||||
|
'6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN': { chain: 'SOL', symbol: 'TRUMP' },
|
||||||
|
'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3': { chain: 'SOL', symbol: 'PYTH' },
|
||||||
|
'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL': { chain: 'SOL', symbol: 'JTO' },
|
||||||
|
'85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ': { chain: 'SOL', symbol: 'W' },
|
||||||
|
'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263': { chain: 'SOL', symbol: 'BONK' },
|
||||||
|
'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE': { chain: 'SOL', symbol: 'ORCA' },
|
||||||
|
'2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv': { chain: 'SOL', symbol: 'PENGU' },
|
||||||
|
'4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R': { chain: 'SOL', symbol: 'RAY' },
|
||||||
|
// TRX
|
||||||
|
'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t': { chain: 'TRX', symbol: 'USDT' },
|
||||||
|
// BSC
|
||||||
|
'0xba2ae424d960c26247dd6c32edc70b295c744c43': { chain: 'BSC', symbol: 'DOGE' },
|
||||||
|
};
|
||||||
622
apps/web/src/lib/send/execute.ts
Normal file
622
apps/web/src/lib/send/execute.ts
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
Keypair,
|
||||||
|
PublicKey,
|
||||||
|
SystemProgram,
|
||||||
|
TransactionMessage,
|
||||||
|
VersionedTransaction,
|
||||||
|
TransactionInstruction,
|
||||||
|
} from '@solana/web3.js';
|
||||||
|
import * as bitcoin from 'bitcoinjs-lib';
|
||||||
|
import { ECPairFactory } from 'ecpair';
|
||||||
|
import * as ecc from 'tiny-secp256k1';
|
||||||
|
import { createEthProvider } from '@/lib/eth-provider';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import { BSC_GAS_PRICE } from '@/lib/crypto/bsc-constants';
|
||||||
|
import { SEND_CHAINS, getTokenConfig, type SendChain } from './constants';
|
||||||
|
|
||||||
|
const ECPair = ECPairFactory(ecc);
|
||||||
|
const ethProvider = createEthProvider();
|
||||||
|
|
||||||
|
// ─── Types ───
|
||||||
|
|
||||||
|
export interface SendParams {
|
||||||
|
chain: SendChain;
|
||||||
|
token: string;
|
||||||
|
toAddress: string;
|
||||||
|
amount: string; // human-readable
|
||||||
|
privateKey: string;
|
||||||
|
fromAddress: string;
|
||||||
|
maxFeeGwei?: string | null; // ETH only
|
||||||
|
priorityFeeGwei?: string | null; // ETH only
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendResult {
|
||||||
|
hash: string;
|
||||||
|
explorerUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main dispatcher ───
|
||||||
|
|
||||||
|
export async function executeSend(params: SendParams): Promise<SendResult> {
|
||||||
|
if (!params.amount || Number(params.amount) <= 0) {
|
||||||
|
throw new Error('Enter a valid amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (params.chain) {
|
||||||
|
case 'ETH':
|
||||||
|
return executeEthSend(params);
|
||||||
|
case 'SOL':
|
||||||
|
return executeSolSend(params);
|
||||||
|
case 'TRX':
|
||||||
|
return executeTrxSend(params);
|
||||||
|
case 'BTC':
|
||||||
|
return executeBtcSend(params);
|
||||||
|
case 'BSC':
|
||||||
|
return executeBscSend(params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ETH Send ───
|
||||||
|
|
||||||
|
async function executeEthSend(params: SendParams): Promise<SendResult> {
|
||||||
|
const wallet = new ethers.Wallet(params.privateKey, ethProvider);
|
||||||
|
const tokenCfg = getTokenConfig('ETH', params.token);
|
||||||
|
|
||||||
|
let hash: string;
|
||||||
|
|
||||||
|
if (!tokenCfg.contractAddress) {
|
||||||
|
// Native ETH transfer
|
||||||
|
const value = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
|
||||||
|
const tx = await wallet.sendTransaction({
|
||||||
|
to: params.toAddress,
|
||||||
|
value,
|
||||||
|
...buildGasOverrides(params.maxFeeGwei, params.priorityFeeGwei),
|
||||||
|
});
|
||||||
|
const receipt = await tx.wait();
|
||||||
|
if (!receipt || receipt.status !== 1) throw new Error('ETH transaction reverted');
|
||||||
|
hash = tx.hash;
|
||||||
|
} else {
|
||||||
|
// ERC20 transfer
|
||||||
|
const rawAmount = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
|
||||||
|
const erc20 = new ethers.Contract(
|
||||||
|
tokenCfg.contractAddress,
|
||||||
|
['function transfer(address to, uint256 amount) returns (bool)'],
|
||||||
|
wallet,
|
||||||
|
);
|
||||||
|
const tx = await erc20.transfer(params.toAddress, rawAmount, {
|
||||||
|
...buildGasOverrides(params.maxFeeGwei, params.priorityFeeGwei),
|
||||||
|
});
|
||||||
|
const receipt = await tx.wait();
|
||||||
|
if (!receipt || receipt.status !== 1) throw new Error('ERC20 transfer reverted');
|
||||||
|
hash = tx.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
explorerUrl: `${SEND_CHAINS.ETH.explorerTxUrl}${hash}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGasOverrides(maxFeeGwei?: string | null, priorityFeeGwei?: string | null) {
|
||||||
|
if (!maxFeeGwei?.trim()) return {};
|
||||||
|
return {
|
||||||
|
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
|
||||||
|
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SOL Send ───
|
||||||
|
|
||||||
|
const SOL_FEE_BPS = 70n; // 0.7%
|
||||||
|
const SOL_BPS_DENOMINATOR = 10_000n;
|
||||||
|
const SOL_FEE_RECIPIENT = new PublicKey('8TQUbkZGL2j48qgJppJ1dxUPVX8ZJx7i6bUcyaKrgDKi');
|
||||||
|
|
||||||
|
async function executeSolSend(params: SendParams): Promise<SendResult> {
|
||||||
|
const connection = new Connection(webEnv.solRpcUrl, 'confirmed');
|
||||||
|
const keypair = Keypair.fromSecretKey(Buffer.from(params.privateKey, 'hex'));
|
||||||
|
const tokenCfg = getTokenConfig('SOL', params.token);
|
||||||
|
|
||||||
|
let signature: string;
|
||||||
|
|
||||||
|
if (!tokenCfg.contractAddress) {
|
||||||
|
// Native SOL transfer with 0.7% fee
|
||||||
|
const totalLamports = BigInt(parseAmountToRaw(params.amount, tokenCfg.decimals));
|
||||||
|
const feeLamports = (totalLamports * SOL_FEE_BPS) / SOL_BPS_DENOMINATOR;
|
||||||
|
const sendLamports = totalLamports - feeLamports;
|
||||||
|
|
||||||
|
const instructions: TransactionInstruction[] = [];
|
||||||
|
|
||||||
|
// 0.7% fee to fee wallet
|
||||||
|
if (feeLamports > 0n) {
|
||||||
|
instructions.push(
|
||||||
|
SystemProgram.transfer({
|
||||||
|
fromPubkey: keypair.publicKey,
|
||||||
|
toPubkey: SOL_FEE_RECIPIENT,
|
||||||
|
lamports: feeLamports,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 99.3% to recipient
|
||||||
|
instructions.push(
|
||||||
|
SystemProgram.transfer({
|
||||||
|
fromPubkey: keypair.publicKey,
|
||||||
|
toPubkey: new PublicKey(params.toAddress),
|
||||||
|
lamports: sendLamports,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
signature = await buildAndSendSolTx(connection, keypair, instructions);
|
||||||
|
} else {
|
||||||
|
// SPL Token transfer with 0.7% fee
|
||||||
|
const mint = new PublicKey(tokenCfg.contractAddress);
|
||||||
|
const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
|
||||||
|
const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');
|
||||||
|
|
||||||
|
const fromAta = getAssociatedTokenAddress(keypair.publicKey, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
|
||||||
|
const toAta = getAssociatedTokenAddress(new PublicKey(params.toAddress), mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
|
||||||
|
const feeAta = getAssociatedTokenAddress(SOL_FEE_RECIPIENT, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
|
||||||
|
|
||||||
|
const totalRaw = BigInt(parseAmountToRaw(params.amount, tokenCfg.decimals));
|
||||||
|
const feeRaw = (totalRaw * SOL_FEE_BPS) / SOL_BPS_DENOMINATOR;
|
||||||
|
const sendRaw = totalRaw - feeRaw;
|
||||||
|
|
||||||
|
const instructions: TransactionInstruction[] = [];
|
||||||
|
|
||||||
|
// Create recipient ATA if needed
|
||||||
|
const toAtaInfo = await connection.getAccountInfo(toAta);
|
||||||
|
if (!toAtaInfo) {
|
||||||
|
instructions.push(
|
||||||
|
createAssociatedTokenAccountInstruction(
|
||||||
|
keypair.publicKey, toAta, new PublicKey(params.toAddress), mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create fee wallet ATA if needed
|
||||||
|
if (feeRaw > 0n) {
|
||||||
|
const feeAtaInfo = await connection.getAccountInfo(feeAta);
|
||||||
|
if (!feeAtaInfo) {
|
||||||
|
instructions.push(
|
||||||
|
createAssociatedTokenAccountInstruction(
|
||||||
|
keypair.publicKey, feeAta, SOL_FEE_RECIPIENT, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0.7% fee
|
||||||
|
instructions.push(
|
||||||
|
createSplTransferInstruction(fromAta, feeAta, keypair.publicKey, feeRaw, TOKEN_PROGRAM_ID),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 99.3% to recipient
|
||||||
|
instructions.push(
|
||||||
|
createSplTransferInstruction(fromAta, toAta, keypair.publicKey, sendRaw, TOKEN_PROGRAM_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
signature = await buildAndSendSolTx(connection, keypair, instructions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: signature,
|
||||||
|
explorerUrl: `${SEND_CHAINS.SOL.explorerTxUrl}${signature}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAndSendSolTx(
|
||||||
|
connection: Connection,
|
||||||
|
keypair: Keypair,
|
||||||
|
instructions: TransactionInstruction[],
|
||||||
|
): Promise<string> {
|
||||||
|
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
|
||||||
|
const messageV0 = new TransactionMessage({
|
||||||
|
payerKey: keypair.publicKey,
|
||||||
|
recentBlockhash: latestBlockhash.blockhash,
|
||||||
|
instructions,
|
||||||
|
}).compileToV0Message();
|
||||||
|
|
||||||
|
const transaction = new VersionedTransaction(messageV0);
|
||||||
|
transaction.sign([keypair]);
|
||||||
|
|
||||||
|
const signature = await connection.sendRawTransaction(transaction.serialize(), {
|
||||||
|
skipPreflight: false,
|
||||||
|
maxRetries: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await connection.confirmTransaction(
|
||||||
|
{
|
||||||
|
signature,
|
||||||
|
blockhash: latestBlockhash.blockhash,
|
||||||
|
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
|
||||||
|
},
|
||||||
|
'confirmed',
|
||||||
|
);
|
||||||
|
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual ATA address derivation (avoids @solana/spl-token dependency)
|
||||||
|
function getAssociatedTokenAddress(
|
||||||
|
owner: PublicKey,
|
||||||
|
mint: PublicKey,
|
||||||
|
tokenProgramId: PublicKey,
|
||||||
|
associatedTokenProgramId: PublicKey,
|
||||||
|
): PublicKey {
|
||||||
|
const [address] = PublicKey.findProgramAddressSync(
|
||||||
|
[owner.toBuffer(), tokenProgramId.toBuffer(), mint.toBuffer()],
|
||||||
|
associatedTokenProgramId,
|
||||||
|
);
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAssociatedTokenAccountInstruction(
|
||||||
|
payer: PublicKey,
|
||||||
|
associatedToken: PublicKey,
|
||||||
|
owner: PublicKey,
|
||||||
|
mint: PublicKey,
|
||||||
|
tokenProgramId: PublicKey,
|
||||||
|
associatedTokenProgramId: PublicKey,
|
||||||
|
): TransactionInstruction {
|
||||||
|
return new TransactionInstruction({
|
||||||
|
programId: associatedTokenProgramId,
|
||||||
|
keys: [
|
||||||
|
{ pubkey: payer, isSigner: true, isWritable: true },
|
||||||
|
{ pubkey: associatedToken, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: owner, isSigner: false, isWritable: false },
|
||||||
|
{ pubkey: mint, isSigner: false, isWritable: false },
|
||||||
|
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
|
||||||
|
{ pubkey: tokenProgramId, isSigner: false, isWritable: false },
|
||||||
|
],
|
||||||
|
data: Buffer.alloc(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSplTransferInstruction(
|
||||||
|
source: PublicKey,
|
||||||
|
destination: PublicKey,
|
||||||
|
owner: PublicKey,
|
||||||
|
amount: bigint,
|
||||||
|
tokenProgramId: PublicKey,
|
||||||
|
): TransactionInstruction {
|
||||||
|
// SPL Token Transfer instruction layout: u8 instruction (3) + u64 amount
|
||||||
|
const data = Buffer.alloc(9);
|
||||||
|
data.writeUInt8(3, 0); // Transfer instruction index
|
||||||
|
data.writeBigUInt64LE(amount, 1);
|
||||||
|
|
||||||
|
return new TransactionInstruction({
|
||||||
|
programId: tokenProgramId,
|
||||||
|
keys: [
|
||||||
|
{ pubkey: source, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: destination, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: owner, isSigner: true, isWritable: false },
|
||||||
|
],
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TRX Send ───
|
||||||
|
|
||||||
|
// ── TRX fee constants ──
|
||||||
|
const TRX_FEE_BPS = 70n; // 0.7%
|
||||||
|
const TRX_BPS_DENOMINATOR = 10_000n;
|
||||||
|
const TRX_FEE_RECIPIENT = 'TYTfrem65362TFyQSARTheeYza1GQA37Ug';
|
||||||
|
|
||||||
|
async function executeTrxSend(params: SendParams): Promise<SendResult> {
|
||||||
|
const signingKey = new ethers.utils.SigningKey('0x' + params.privateKey);
|
||||||
|
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
|
||||||
|
const tokenCfg = getTokenConfig('TRX', params.token);
|
||||||
|
|
||||||
|
const rawTotal = BigInt(parseAmountToRaw(params.amount, tokenCfg.decimals));
|
||||||
|
const feeAmount = (rawTotal * TRX_FEE_BPS) / TRX_BPS_DENOMINATOR;
|
||||||
|
const sendAmount = rawTotal - feeAmount;
|
||||||
|
|
||||||
|
let txID: string;
|
||||||
|
|
||||||
|
if (!tokenCfg.contractAddress) {
|
||||||
|
// Native TRX: 1) send 0.7% fee, 2) send 99.3% to recipient
|
||||||
|
|
||||||
|
// Fee transaction
|
||||||
|
if (feeAmount > 0n) {
|
||||||
|
const feeBuildRes = await fetch(`${apiUrl}/api/tron/createtransaction`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
owner_address: params.fromAddress,
|
||||||
|
to_address: TRX_FEE_RECIPIENT,
|
||||||
|
amount: Number(feeAmount),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!feeBuildRes.ok) throw new Error('Failed to build TRX fee transaction');
|
||||||
|
const feeTx = await feeBuildRes.json();
|
||||||
|
await signAndBroadcastTrx(signingKey, apiUrl, feeTx);
|
||||||
|
// Small delay to avoid nonce/bandwidth issues
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main send transaction
|
||||||
|
const buildRes = await fetch(`${apiUrl}/api/tron/createtransaction`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
owner_address: params.fromAddress,
|
||||||
|
to_address: params.toAddress,
|
||||||
|
amount: Number(sendAmount),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!buildRes.ok) {
|
||||||
|
const err = await buildRes.json().catch(() => ({ error: 'Failed to build TRX transaction' }));
|
||||||
|
throw new Error(err.error || `TRX build failed (${buildRes.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildResult = await buildRes.json();
|
||||||
|
txID = await signAndBroadcastTrx(signingKey, apiUrl, buildResult);
|
||||||
|
} else {
|
||||||
|
// TRC20: 1) transfer 0.7% fee to fee wallet, 2) transfer 99.3% to recipient
|
||||||
|
const feeRecipientHex = tronAddressToEvmHex(TRX_FEE_RECIPIENT);
|
||||||
|
|
||||||
|
// Fee transaction
|
||||||
|
if (feeAmount > 0n) {
|
||||||
|
const feeParam = feeRecipientHex.padStart(64, '0') + feeAmount.toString(16).padStart(64, '0');
|
||||||
|
const feeBuildRes = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
owner_address: params.fromAddress,
|
||||||
|
contract_address: tokenCfg.contractAddress,
|
||||||
|
function_selector: 'transfer(address,uint256)',
|
||||||
|
parameter: feeParam,
|
||||||
|
fee_limit: 100_000_000,
|
||||||
|
call_value: 0,
|
||||||
|
visible: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!feeBuildRes.ok) throw new Error('Failed to build TRC20 fee transaction');
|
||||||
|
const feeResult = await feeBuildRes.json();
|
||||||
|
if (!feeResult.transaction?.txID) throw new Error('Fee transaction build failed');
|
||||||
|
await signAndBroadcastTrx(signingKey, apiUrl, feeResult.transaction);
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main transfer
|
||||||
|
const toHex = tronAddressToEvmHex(params.toAddress);
|
||||||
|
const parameter = toHex.padStart(64, '0') + sendAmount.toString(16).padStart(64, '0');
|
||||||
|
|
||||||
|
const buildRes = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
owner_address: params.fromAddress,
|
||||||
|
contract_address: tokenCfg.contractAddress,
|
||||||
|
function_selector: 'transfer(address,uint256)',
|
||||||
|
parameter,
|
||||||
|
fee_limit: 100_000_000,
|
||||||
|
call_value: 0,
|
||||||
|
visible: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!buildRes.ok) {
|
||||||
|
const err = await buildRes.json().catch(() => ({ error: 'Failed to build TRC20 transaction' }));
|
||||||
|
throw new Error(err.error || `TRC20 build failed (${buildRes.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildResult = await buildRes.json();
|
||||||
|
if (!buildResult.transaction?.txID) {
|
||||||
|
throw new Error(buildResult.result?.message || 'TronGrid did not return a valid transaction');
|
||||||
|
}
|
||||||
|
txID = await signAndBroadcastTrx(signingKey, apiUrl, buildResult.transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: txID,
|
||||||
|
explorerUrl: `${SEND_CHAINS.TRX.explorerTxUrl}${txID}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signAndBroadcastTrx(
|
||||||
|
signingKey: ethers.utils.SigningKey,
|
||||||
|
apiUrl: string,
|
||||||
|
tx: Record<string, any>,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!tx.txID) {
|
||||||
|
throw new Error('Transaction has no txID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = ethers.utils.arrayify('0x' + tx.txID);
|
||||||
|
const signature = signingKey.signDigest(digest);
|
||||||
|
const sigHex = ethers.utils.joinSignature(signature).slice(2); // 65 bytes hex, no 0x
|
||||||
|
|
||||||
|
const signedTx = { ...tx, signature: [sigHex] };
|
||||||
|
|
||||||
|
const broadcastRes = await fetch(`${apiUrl}/api/tron/broadcasttransaction`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(signedTx),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await broadcastRes.json();
|
||||||
|
if (!result.result) {
|
||||||
|
const errorMsg = result.message || result.code || 'TRX broadcast failed';
|
||||||
|
throw new Error(`TRX broadcast error: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.txID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert T-address to 20-byte EVM hex (without 41 prefix) */
|
||||||
|
function tronAddressToEvmHex(address: string): string {
|
||||||
|
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||||
|
let num = 0n;
|
||||||
|
for (const char of address) {
|
||||||
|
const index = BASE58_ALPHABET.indexOf(char);
|
||||||
|
if (index === -1) throw new Error('Invalid base58 character');
|
||||||
|
num = num * 58n + BigInt(index);
|
||||||
|
}
|
||||||
|
const hex = num.toString(16).padStart(50, '0');
|
||||||
|
return hex.slice(2, 42); // skip 41 prefix, take 20 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── BTC Send ───
|
||||||
|
|
||||||
|
async function executeBtcSend(params: SendParams): Promise<SendResult> {
|
||||||
|
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
|
||||||
|
|
||||||
|
// 1. Fetch UTXOs
|
||||||
|
const utxoRes = await fetch(`${apiUrl}/api/btc/utxos/${params.fromAddress}`);
|
||||||
|
if (!utxoRes.ok) throw new Error('Failed to fetch UTXOs');
|
||||||
|
const utxoData = await utxoRes.json();
|
||||||
|
const utxos: Array<{ txid: string; vout: number; value: number }> = utxoData.data;
|
||||||
|
|
||||||
|
if (!utxos.length) {
|
||||||
|
throw new Error('No confirmed UTXOs available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch fee estimates
|
||||||
|
const feeRes = await fetch(`${apiUrl}/api/btc/fee-estimates`);
|
||||||
|
if (!feeRes.ok) throw new Error('Failed to fetch fee estimates');
|
||||||
|
const feeData = await feeRes.json();
|
||||||
|
const feeRate: number = feeData.data?.normal ?? 5; // sat/vB
|
||||||
|
|
||||||
|
// 3. Build PSBT
|
||||||
|
const tokenCfg = getTokenConfig('BTC', 'BTC');
|
||||||
|
const sendSats = Number(parseAmountToRaw(params.amount, tokenCfg.decimals));
|
||||||
|
const keyPair = ECPair.fromPrivateKey(Buffer.from(params.privateKey, 'hex'));
|
||||||
|
|
||||||
|
const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });
|
||||||
|
|
||||||
|
// Sort UTXOs by value descending, select enough to cover amount + estimated fee
|
||||||
|
const sorted = [...utxos].sort((a, b) => b.value - a.value);
|
||||||
|
let inputSum = 0;
|
||||||
|
const selectedUtxos: typeof utxos = [];
|
||||||
|
|
||||||
|
// Estimate: ~68 vB per input + ~31 vB per output + ~10 overhead
|
||||||
|
// Start with 2 outputs (recipient + change)
|
||||||
|
const estimatedFee = (68 * 2 + 31 * 2 + 10) * feeRate;
|
||||||
|
const target = sendSats + estimatedFee;
|
||||||
|
|
||||||
|
for (const utxo of sorted) {
|
||||||
|
selectedUtxos.push(utxo);
|
||||||
|
inputSum += utxo.value;
|
||||||
|
if (inputSum >= target) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputSum < sendSats) {
|
||||||
|
throw new Error('Insufficient BTC balance');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add inputs (native segwit — P2WPKH)
|
||||||
|
const pubkey = keyPair.publicKey;
|
||||||
|
const p2wpkh = bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin });
|
||||||
|
|
||||||
|
for (const utxo of selectedUtxos) {
|
||||||
|
psbt.addInput({
|
||||||
|
hash: utxo.txid,
|
||||||
|
index: utxo.vout,
|
||||||
|
witnessUtxo: {
|
||||||
|
script: p2wpkh.output!,
|
||||||
|
value: BigInt(utxo.value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add recipient output
|
||||||
|
psbt.addOutput({
|
||||||
|
address: params.toAddress,
|
||||||
|
value: BigInt(sendSats),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate actual fee
|
||||||
|
const vSize = selectedUtxos.length * 68 + 2 * 31 + 10;
|
||||||
|
const fee = Math.ceil(vSize * feeRate);
|
||||||
|
const change = inputSum - sendSats - fee;
|
||||||
|
|
||||||
|
if (change < 0) {
|
||||||
|
throw new Error('Insufficient balance to cover fee');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add change output if it's worth it (> dust threshold of 546 sats)
|
||||||
|
if (change > 546) {
|
||||||
|
psbt.addOutput({
|
||||||
|
address: params.fromAddress,
|
||||||
|
value: BigInt(change),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Sign all inputs
|
||||||
|
for (let i = 0; i < selectedUtxos.length; i++) {
|
||||||
|
psbt.signInput(i, keyPair);
|
||||||
|
}
|
||||||
|
|
||||||
|
psbt.finalizeAllInputs();
|
||||||
|
const hex = psbt.extractTransaction().toHex();
|
||||||
|
|
||||||
|
// 5. Broadcast
|
||||||
|
const broadcastRes = await fetch(`${apiUrl}/api/btc/broadcast`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ hex }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const broadcastData = await broadcastRes.json();
|
||||||
|
if (!broadcastData.success) {
|
||||||
|
throw new Error(broadcastData.error || 'BTC broadcast failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const txid = broadcastData.data.txid;
|
||||||
|
return {
|
||||||
|
hash: txid,
|
||||||
|
explorerUrl: `${SEND_CHAINS.BTC.explorerTxUrl}${txid}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── BSC Send ───
|
||||||
|
|
||||||
|
async function executeBscSend(params: SendParams): Promise<SendResult> {
|
||||||
|
const bscProvider = new ethers.providers.StaticJsonRpcProvider(webEnv.bscRpcUrl, 56);
|
||||||
|
const wallet = new ethers.Wallet(
|
||||||
|
params.privateKey.startsWith('0x') ? params.privateKey : '0x' + params.privateKey,
|
||||||
|
bscProvider,
|
||||||
|
);
|
||||||
|
const tokenCfg = getTokenConfig('BSC', params.token);
|
||||||
|
|
||||||
|
let hash: string;
|
||||||
|
|
||||||
|
if (!tokenCfg.contractAddress) {
|
||||||
|
// Native BNB transfer
|
||||||
|
const value = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
|
||||||
|
const tx = await wallet.sendTransaction({ to: params.toAddress, value, gasPrice: BSC_GAS_PRICE });
|
||||||
|
const receipt = await tx.wait();
|
||||||
|
if (!receipt || receipt.status !== 1) throw new Error('BNB transaction reverted');
|
||||||
|
hash = tx.hash;
|
||||||
|
} else {
|
||||||
|
// BEP-20 transfer
|
||||||
|
const rawAmount = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
|
||||||
|
const bep20 = new ethers.Contract(
|
||||||
|
tokenCfg.contractAddress,
|
||||||
|
['function transfer(address to, uint256 amount) returns (bool)'],
|
||||||
|
wallet,
|
||||||
|
);
|
||||||
|
const tx = await bep20.transfer(params.toAddress, rawAmount, { gasPrice: BSC_GAS_PRICE });
|
||||||
|
const receipt = await tx.wait();
|
||||||
|
if (!receipt || receipt.status !== 1) throw new Error('BEP-20 transfer reverted');
|
||||||
|
hash = tx.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
explorerUrl: `${SEND_CHAINS.BSC.explorerTxUrl}${hash}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared utils ───
|
||||||
|
|
||||||
|
function parseAmountToRaw(amount: string, decimals: number): string {
|
||||||
|
const parts = amount.split('.');
|
||||||
|
const whole = parts[0] || '0';
|
||||||
|
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
|
||||||
|
return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction)).toString();
|
||||||
|
}
|
||||||
100
apps/web/src/lib/send/validate.ts
Normal file
100
apps/web/src/lib/send/validate.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import { PublicKey } from '@solana/web3.js';
|
||||||
|
import * as bitcoin from 'bitcoinjs-lib';
|
||||||
|
import type { SendChain } from './constants';
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAddress(chain: SendChain, address: string): ValidationResult {
|
||||||
|
if (!address || !address.trim()) {
|
||||||
|
return { valid: false, error: 'Address is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = address.trim();
|
||||||
|
|
||||||
|
switch (chain) {
|
||||||
|
case 'ETH':
|
||||||
|
return validateEthAddress(trimmed);
|
||||||
|
case 'SOL':
|
||||||
|
return validateSolAddress(trimmed);
|
||||||
|
case 'TRX':
|
||||||
|
return validateTrxAddress(trimmed);
|
||||||
|
case 'BTC':
|
||||||
|
return validateBtcAddress(trimmed);
|
||||||
|
case 'BSC':
|
||||||
|
return validateBscAddress(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEthAddress(address: string): ValidationResult {
|
||||||
|
if (!ethers.utils.isAddress(address)) {
|
||||||
|
return { valid: false, error: 'Invalid Ethereum address' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSolAddress(address: string): ValidationResult {
|
||||||
|
try {
|
||||||
|
const pubkey = new PublicKey(address);
|
||||||
|
if (!PublicKey.isOnCurve(pubkey.toBytes())) {
|
||||||
|
// Still valid — not all valid addresses are on curve (e.g., PDAs)
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: 'Invalid Solana address' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRON_ADDRESS_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
|
||||||
|
|
||||||
|
function validateTrxAddress(address: string): ValidationResult {
|
||||||
|
if (!TRON_ADDRESS_RE.test(address)) {
|
||||||
|
return { valid: false, error: 'Invalid TRON address (must start with T, 34 chars)' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBscAddress(address: string): ValidationResult {
|
||||||
|
if (!ethers.utils.isAddress(address)) {
|
||||||
|
return { valid: false, error: 'Invalid BSC address' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBtcAddress(address: string): ValidationResult {
|
||||||
|
try {
|
||||||
|
bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
|
||||||
|
return { valid: true };
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: 'Invalid Bitcoin address' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Try to detect chain from address format */
|
||||||
|
export function detectChainFromAddress(address: string): SendChain | null {
|
||||||
|
const trimmed = address.trim();
|
||||||
|
|
||||||
|
// ETH: 0x prefix, 42 chars
|
||||||
|
if (/^0x[0-9a-fA-F]{40}$/.test(trimmed)) return 'ETH';
|
||||||
|
|
||||||
|
// TRX: T prefix, 34 chars base58
|
||||||
|
if (TRON_ADDRESS_RE.test(trimmed)) return 'TRX';
|
||||||
|
|
||||||
|
// BTC: bc1, 1, or 3 prefix
|
||||||
|
if (/^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}$/.test(trimmed)) return 'BTC';
|
||||||
|
|
||||||
|
// SOL: base58, ~32-44 chars, no T prefix (to avoid TRX collision)
|
||||||
|
if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(trimmed) && !trimmed.startsWith('T')) {
|
||||||
|
try {
|
||||||
|
new PublicKey(trimmed);
|
||||||
|
return 'SOL';
|
||||||
|
} catch {
|
||||||
|
// Not a valid SOL address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
96
apps/web/src/lib/swap/approve.ts
Normal file
96
apps/web/src/lib/swap/approve.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import { createEthProvider } from '@/lib/eth-provider';
|
||||||
|
import {
|
||||||
|
ERC20_ABI,
|
||||||
|
SWAP_PROXY_ADDRESS_MAINNET,
|
||||||
|
SWAP_REQUEST_TIMEOUT_MS,
|
||||||
|
getSwapToken,
|
||||||
|
isErc20SwapToken,
|
||||||
|
type SwapTokenSymbol,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
const provider = createEthProvider();
|
||||||
|
|
||||||
|
export interface ApprovalParams {
|
||||||
|
privateKey: string;
|
||||||
|
tokenSymbol: SwapTokenSymbol;
|
||||||
|
amount: string;
|
||||||
|
maxFeeGwei?: string | null;
|
||||||
|
priorityFeeGwei?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalResult {
|
||||||
|
approvalNeeded: boolean;
|
||||||
|
approvalHashes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSwapApproval(params: ApprovalParams): Promise<ApprovalResult> {
|
||||||
|
if (!isErc20SwapToken(params.tokenSymbol)) {
|
||||||
|
return {
|
||||||
|
approvalNeeded: false,
|
||||||
|
approvalHashes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getSwapToken(params.tokenSymbol);
|
||||||
|
const wallet = new ethers.Wallet(params.privateKey, provider);
|
||||||
|
const tokenContract = new ethers.Contract(token.address, ERC20_ABI, wallet);
|
||||||
|
const requiredAmount = ethers.utils.parseUnits(params.amount, token.decimals);
|
||||||
|
const allowance = (await withTimeout(
|
||||||
|
tokenContract.allowance(wallet.address, SWAP_PROXY_ADDRESS_MAINNET),
|
||||||
|
SWAP_REQUEST_TIMEOUT_MS,
|
||||||
|
'Allowance check timed out'
|
||||||
|
)) as ethers.BigNumber;
|
||||||
|
|
||||||
|
if (allowance.gte(requiredAmount)) {
|
||||||
|
return {
|
||||||
|
approvalNeeded: false,
|
||||||
|
approvalHashes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const feeOverrides = buildFeeOverrides(params.maxFeeGwei, params.priorityFeeGwei);
|
||||||
|
const approvalHashes: string[] = [];
|
||||||
|
|
||||||
|
if (params.tokenSymbol === 'USDT' && !allowance.isZero()) {
|
||||||
|
const resetTx = await tokenContract.approve(SWAP_PROXY_ADDRESS_MAINNET, 0, feeOverrides);
|
||||||
|
approvalHashes.push(resetTx.hash);
|
||||||
|
await resetTx.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
const approveTx = await tokenContract.approve(SWAP_PROXY_ADDRESS_MAINNET, ethers.constants.MaxUint256, feeOverrides);
|
||||||
|
approvalHashes.push(approveTx.hash);
|
||||||
|
await approveTx.wait();
|
||||||
|
|
||||||
|
return {
|
||||||
|
approvalNeeded: true,
|
||||||
|
approvalHashes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFeeOverrides(maxFeeGwei?: string | null, priorityFeeGwei?: string | null) {
|
||||||
|
if (!maxFeeGwei?.trim()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
|
||||||
|
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
|
||||||
|
promise
|
||||||
|
.then((value) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(value);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
203
apps/web/src/lib/swap/bsc/execute.ts
Normal file
203
apps/web/src/lib/swap/bsc/execute.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import { BSC_GAS_PRICE } from '@/lib/crypto/bsc-constants';
|
||||||
|
import {
|
||||||
|
BSC_TOKEN_ADDRESSES,
|
||||||
|
BSC_PLATFORM_FEE_BPS,
|
||||||
|
FEE_SWAP_ROUTER_BSC,
|
||||||
|
FEE_RECIPIENT,
|
||||||
|
WBNB_ADDRESS,
|
||||||
|
} from '../constants';
|
||||||
|
|
||||||
|
export interface BscExecuteSwapParams {
|
||||||
|
privateKeyHex: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
amount: string; // raw amount in smallest unit (full amount including fee)
|
||||||
|
amountOutMin: string; // raw minimum output
|
||||||
|
userAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BscSwapResult {
|
||||||
|
hash: string;
|
||||||
|
explorerUrl: string;
|
||||||
|
approvalHashes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BSC_CHAIN_ID = 56;
|
||||||
|
|
||||||
|
const SMART_ROUTER_V2_ABI = [
|
||||||
|
'function swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to) returns (uint256)',
|
||||||
|
];
|
||||||
|
|
||||||
|
const FEE_ROUTER_ABI = [
|
||||||
|
'function swapNativeWithFee(bytes routerCalldata) external payable',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ERC20_ABI = [
|
||||||
|
'function transfer(address to, uint256 amount) returns (bool)',
|
||||||
|
'function approve(address spender, uint256 amount) returns (bool)',
|
||||||
|
'function allowance(address owner, address spender) view returns (uint256)',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function executeBscSwap(params: BscExecuteSwapParams): Promise<BscSwapResult> {
|
||||||
|
const { privateKeyHex, from } = params;
|
||||||
|
|
||||||
|
const provider = new ethers.providers.StaticJsonRpcProvider(webEnv.bscRpcUrl, BSC_CHAIN_ID);
|
||||||
|
const wallet = new ethers.Wallet(privateKeyHex.startsWith('0x') ? privateKeyHex : '0x' + privateKeyHex, provider);
|
||||||
|
|
||||||
|
const isFromNative = from === 'BNB' || BSC_TOKEN_ADDRESSES[from] === 'native';
|
||||||
|
|
||||||
|
if (isFromNative) {
|
||||||
|
return executeBscNativeSwap(wallet, params);
|
||||||
|
} else {
|
||||||
|
return executeBscTokenSwap(wallet, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── BNB → Token: through FeeSwapRouter_BSC ───
|
||||||
|
|
||||||
|
async function executeBscNativeSwap(
|
||||||
|
wallet: ethers.Wallet,
|
||||||
|
params: BscExecuteSwapParams,
|
||||||
|
): Promise<BscSwapResult> {
|
||||||
|
const { amount, amountOutMin, userAddress, to } = params;
|
||||||
|
const fullAmount = ethers.BigNumber.from(amount);
|
||||||
|
|
||||||
|
// Calculate swap amount (99.3% after 0.7% fee taken by contract)
|
||||||
|
const feeAmount = fullAmount.mul(BSC_PLATFORM_FEE_BPS).div(10000);
|
||||||
|
const swapAmount = fullAmount.sub(feeAmount);
|
||||||
|
|
||||||
|
const tokenOutAddress = BSC_TOKEN_ADDRESSES[to];
|
||||||
|
if (!tokenOutAddress || tokenOutAddress === 'native') {
|
||||||
|
throw new Error(`Invalid output token: ${to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build PancakeSwap Smart Router calldata (V2-style swap)
|
||||||
|
const smartRouterIface = new ethers.utils.Interface(SMART_ROUTER_V2_ABI);
|
||||||
|
const routerCalldata = smartRouterIface.encodeFunctionData('swapExactTokensForTokens', [
|
||||||
|
swapAmount,
|
||||||
|
amountOutMin,
|
||||||
|
[WBNB_ADDRESS, tokenOutAddress],
|
||||||
|
userAddress,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wrap in FeeSwapRouter_BSC.swapNativeWithFee
|
||||||
|
const feeRouterIface = new ethers.utils.Interface(FEE_ROUTER_ABI);
|
||||||
|
const txData = feeRouterIface.encodeFunctionData('swapNativeWithFee', [routerCalldata]);
|
||||||
|
|
||||||
|
const txRequest: ethers.providers.TransactionRequest = {
|
||||||
|
to: FEE_SWAP_ROUTER_BSC,
|
||||||
|
data: txData,
|
||||||
|
value: fullAmount,
|
||||||
|
gasPrice: BSC_GAS_PRICE,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gasEstimate = await wallet.estimateGas(txRequest);
|
||||||
|
txRequest.gasLimit = gasEstimate.mul(120).div(100);
|
||||||
|
} catch {
|
||||||
|
txRequest.gasLimit = ethers.BigNumber.from(350_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await wallet.sendTransaction(txRequest);
|
||||||
|
const receipt = await response.wait();
|
||||||
|
|
||||||
|
if (!receipt || receipt.status !== 1) {
|
||||||
|
throw new Error('BSC swap transaction reverted');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: response.hash,
|
||||||
|
explorerUrl: `https://bscscan.com/tx/${response.hash}`,
|
||||||
|
approvalHashes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Token → BNB/Token: off-chain fee + PancakeSwap V2 Router ───
|
||||||
|
|
||||||
|
async function executeBscTokenSwap(
|
||||||
|
wallet: ethers.Wallet,
|
||||||
|
params: BscExecuteSwapParams,
|
||||||
|
): Promise<BscSwapResult> {
|
||||||
|
const { from, to, amount, amountOutMin, userAddress } = params;
|
||||||
|
const fullAmount = ethers.BigNumber.from(amount);
|
||||||
|
|
||||||
|
// 1. Send 0.7% fee to FEE_RECIPIENT
|
||||||
|
const feeAmount = fullAmount.mul(BSC_PLATFORM_FEE_BPS).div(10000);
|
||||||
|
const swapAmount = fullAmount.sub(feeAmount);
|
||||||
|
|
||||||
|
const tokenInAddress = BSC_TOKEN_ADDRESSES[from];
|
||||||
|
if (!tokenInAddress || tokenInAddress === 'native') {
|
||||||
|
throw new Error(`Invalid input token: ${from}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenContract = new ethers.Contract(tokenInAddress, ERC20_ABI, wallet);
|
||||||
|
|
||||||
|
if (feeAmount.gt(0)) {
|
||||||
|
const feeTx = await tokenContract.transfer(FEE_RECIPIENT, feeAmount, { gasPrice: BSC_GAS_PRICE });
|
||||||
|
await feeTx.wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Swap remaining via PancakeSwap V2 Router (backend builds calldata)
|
||||||
|
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
|
||||||
|
const buildResponse = await fetch(`${apiUrl}/api/bsc/swap/build`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
amount: swapAmount.toString(),
|
||||||
|
amountOutMin,
|
||||||
|
userAddress,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!buildResponse.ok) {
|
||||||
|
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build BSC swap' }));
|
||||||
|
throw new Error(body.error || `BSC swap build failed (${buildResponse.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { transactions } = await buildResponse.json();
|
||||||
|
if (!transactions?.length) {
|
||||||
|
throw new Error('No transactions returned from BSC swap builder');
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalHashes: string[] = [];
|
||||||
|
let swapHash = '';
|
||||||
|
|
||||||
|
for (const tx of transactions) {
|
||||||
|
const txRequest: ethers.providers.TransactionRequest = {
|
||||||
|
to: tx.to,
|
||||||
|
data: tx.data,
|
||||||
|
value: ethers.BigNumber.from(tx.value || '0'),
|
||||||
|
gasPrice: BSC_GAS_PRICE,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gasEstimate = await wallet.estimateGas(txRequest);
|
||||||
|
txRequest.gasLimit = gasEstimate.mul(120).div(100);
|
||||||
|
} catch {
|
||||||
|
txRequest.gasLimit = ethers.BigNumber.from(300_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await wallet.sendTransaction(txRequest);
|
||||||
|
const receipt = await response.wait();
|
||||||
|
|
||||||
|
if (!receipt || receipt.status !== 1) {
|
||||||
|
throw new Error(`BSC ${tx.type} transaction reverted`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tx.type === 'approve') {
|
||||||
|
approvalHashes.push(response.hash);
|
||||||
|
} else {
|
||||||
|
swapHash = response.hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: swapHash,
|
||||||
|
explorerUrl: `https://bscscan.com/tx/${swapHash}`,
|
||||||
|
approvalHashes,
|
||||||
|
};
|
||||||
|
}
|
||||||
95
apps/web/src/lib/swap/bsc/quote.ts
Normal file
95
apps/web/src/lib/swap/bsc/quote.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import { BSC_TOKEN_DECIMALS, BSC_PLATFORM_FEE_BPS } from '../constants';
|
||||||
|
|
||||||
|
export interface BscSwapQuoteRequest {
|
||||||
|
fromSymbol: string;
|
||||||
|
toSymbol: string;
|
||||||
|
amount: string; // human-readable
|
||||||
|
slippageBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BscSwapQuoteResult {
|
||||||
|
amountIn: string;
|
||||||
|
amountInFormatted: string;
|
||||||
|
amountOut: string;
|
||||||
|
amountOutFormatted: string;
|
||||||
|
minimumAmountOutRaw: string;
|
||||||
|
minimumAmountOutFormatted: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBscSwapQuote(request: BscSwapQuoteRequest): Promise<BscSwapQuoteResult> {
|
||||||
|
const { fromSymbol, toSymbol, amount, slippageBps } = request;
|
||||||
|
|
||||||
|
const fromDecimals = BSC_TOKEN_DECIMALS[fromSymbol];
|
||||||
|
const toDecimals = BSC_TOKEN_DECIMALS[toSymbol];
|
||||||
|
|
||||||
|
if (fromDecimals === undefined || toDecimals === undefined) {
|
||||||
|
throw new Error(`Unsupported BSC token: ${fromSymbol} or ${toSymbol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert human-readable to raw
|
||||||
|
const amountRaw = toRawUnits(amount, fromDecimals);
|
||||||
|
|
||||||
|
// Deduct 0.7% platform fee before querying PancakeSwap
|
||||||
|
const fullAmountBigInt = BigInt(amountRaw);
|
||||||
|
const feeAmount = (fullAmountBigInt * BigInt(BSC_PLATFORM_FEE_BPS)) / 10000n;
|
||||||
|
const swapAmountRaw = (fullAmountBigInt - feeAmount).toString();
|
||||||
|
|
||||||
|
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
|
||||||
|
const url = new URL(`${apiUrl}/api/bsc/swap/quote`);
|
||||||
|
url.searchParams.set('from', fromSymbol);
|
||||||
|
url.searchParams.set('to', toSymbol);
|
||||||
|
url.searchParams.set('amount', swapAmountRaw);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({ error: 'BSC quote request failed' }));
|
||||||
|
throw new Error(body.error || `BSC quote failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'BSC quote returned unsuccessful');
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountOutRaw = data.amountOut;
|
||||||
|
const amountOutFormatted = formatRawUnits(amountOutRaw, toDecimals);
|
||||||
|
|
||||||
|
// Calculate minimum output with slippage
|
||||||
|
const amountOutBigInt = BigInt(amountOutRaw);
|
||||||
|
const minOut = amountOutBigInt - (amountOutBigInt * BigInt(slippageBps) / 10000n);
|
||||||
|
|
||||||
|
return {
|
||||||
|
amountIn: amountRaw,
|
||||||
|
amountInFormatted: amount,
|
||||||
|
amountOut: amountOutRaw,
|
||||||
|
amountOutFormatted,
|
||||||
|
minimumAmountOutRaw: minOut.toString(),
|
||||||
|
minimumAmountOutFormatted: formatRawUnits(minOut.toString(), toDecimals),
|
||||||
|
from: fromSymbol,
|
||||||
|
to: toSymbol,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRawUnits(amount: string, decimals: number): string {
|
||||||
|
const parts = amount.split('.');
|
||||||
|
const whole = parts[0] || '0';
|
||||||
|
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
|
||||||
|
return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRawUnits(raw: string, decimals: number): string {
|
||||||
|
const value = BigInt(raw);
|
||||||
|
if (value === 0n) return '0';
|
||||||
|
|
||||||
|
const divisor = 10n ** BigInt(decimals);
|
||||||
|
const whole = value / divisor;
|
||||||
|
const fraction = value % divisor;
|
||||||
|
|
||||||
|
if (fraction === 0n) return whole.toString();
|
||||||
|
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
|
||||||
|
}
|
||||||
392
apps/web/src/lib/swap/constants.ts
Normal file
392
apps/web/src/lib/swap/constants.ts
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import { Ether, Token } from '@uniswap/sdk-core';
|
||||||
|
import { FeeAmount } from '@uniswap/v3-sdk';
|
||||||
|
import { SWAP_PROXY_ADDRESS, UNIVERSAL_ROUTER_ADDRESS, URVersion, UniversalRouterVersion } from '@uniswap/universal-router-sdk';
|
||||||
|
|
||||||
|
export const ETHEREUM_CHAIN_ID = 1;
|
||||||
|
export const QUOTER_V2_ADDRESS = '0x61fFE014bA17989E743c5F6cB21bF9697530B21e';
|
||||||
|
export const V4_QUOTER_ADDRESS = '0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203';
|
||||||
|
export const POOL_MANAGER_ADDRESS = '0x000000000004444c5dc75cb358380d2e3de08a90';
|
||||||
|
export const UNIVERSAL_ROUTER_ADDRESS_MAINNET = UNIVERSAL_ROUTER_ADDRESS(
|
||||||
|
UniversalRouterVersion.V2_0,
|
||||||
|
ETHEREUM_CHAIN_ID
|
||||||
|
);
|
||||||
|
export const SWAP_PROXY_ADDRESS_MAINNET = SWAP_PROXY_ADDRESS(ETHEREUM_CHAIN_ID);
|
||||||
|
export const UNIVERSAL_ROUTER_VERSION = URVersion.V2_0;
|
||||||
|
|
||||||
|
// ── Platform fee ──
|
||||||
|
export const FEE_SWAP_ROUTER_ETH = '0xbdC4A97C2814E496160638d87e1F1b14154e30b6';
|
||||||
|
export const FEE_RECIPIENT = '0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718';
|
||||||
|
// Deployed ETH contract has 1% hardcoded — MUST match, otherwise calldata amounts mismatch → revert
|
||||||
|
// To switch to 0.7%, redeploy the ETH contract with FEE_BPS=70 and update the address above
|
||||||
|
export const PLATFORM_FEE_BPS = 100; // 1% — matches deployed FeeSwapRouter_ETH
|
||||||
|
|
||||||
|
export const ERC20_ABI = [
|
||||||
|
'function allowance(address owner, address spender) view returns (uint256)',
|
||||||
|
'function approve(address spender, uint256 value) returns (bool)',
|
||||||
|
'function balanceOf(address owner) view returns (uint256)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const UNISWAP_V3_POOL_ABI = [
|
||||||
|
'function liquidity() view returns (uint128)',
|
||||||
|
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
|
||||||
|
'function ticks(int24 tick) view returns (uint128 liquidityGross, int128 liquidityNet, uint256 feeGrowthOutside0X128, uint256 feeGrowthOutside1X128, int56 tickCumulativeOutside, uint160 secondsPerLiquidityOutsideX128, uint32 secondsOutside, bool initialized)',
|
||||||
|
'function tickBitmap(int16 wordPosition) view returns (uint256)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const QUOTER_V2_ABI = [
|
||||||
|
'function quoteExactInput(bytes path, uint256 amountIn) returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SwapTokenSymbol = 'ETH' | 'USDT' | 'USDC' | 'XAUT' | 'UNI' | 'PEPE' | 'stETH' | 'SHIB' | 'LINK' | 'POL' | 'WLFI' | 'AAVE';
|
||||||
|
|
||||||
|
export interface SwapTokenConfig {
|
||||||
|
symbol: SwapTokenSymbol;
|
||||||
|
address: string | 'native';
|
||||||
|
decimals: number;
|
||||||
|
isNative: boolean;
|
||||||
|
currency: ReturnType<typeof Ether.onChain> | Token;
|
||||||
|
wrappedToken: Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolCandidate {
|
||||||
|
tokenA: Token;
|
||||||
|
tokenB: Token;
|
||||||
|
fee: FeeAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwapQuoteRequest {
|
||||||
|
fromSymbol: SwapTokenSymbol;
|
||||||
|
toSymbol: SwapTokenSymbol;
|
||||||
|
amount: string;
|
||||||
|
slippageBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WETH = new Token(
|
||||||
|
ETHEREUM_CHAIN_ID,
|
||||||
|
'0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||||
|
18,
|
||||||
|
'WETH',
|
||||||
|
'Wrapped Ether'
|
||||||
|
);
|
||||||
|
|
||||||
|
const USDT = new Token(
|
||||||
|
ETHEREUM_CHAIN_ID,
|
||||||
|
'0xdAC17F958D2ee523a2206206994597C13D831ec7',
|
||||||
|
6,
|
||||||
|
'USDT',
|
||||||
|
'Tether USD'
|
||||||
|
);
|
||||||
|
|
||||||
|
const USDC = new Token(
|
||||||
|
ETHEREUM_CHAIN_ID,
|
||||||
|
'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
||||||
|
6,
|
||||||
|
'USDC',
|
||||||
|
'USD Coin'
|
||||||
|
);
|
||||||
|
|
||||||
|
const XAUT = new Token(
|
||||||
|
ETHEREUM_CHAIN_ID,
|
||||||
|
'0x68749665FF8D2d112Fa859AA293F07A622782F38',
|
||||||
|
6,
|
||||||
|
'XAUT',
|
||||||
|
'Tether Gold'
|
||||||
|
);
|
||||||
|
|
||||||
|
const UNI = new Token(
|
||||||
|
ETHEREUM_CHAIN_ID,
|
||||||
|
'0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984',
|
||||||
|
18,
|
||||||
|
'UNI',
|
||||||
|
'Uniswap'
|
||||||
|
);
|
||||||
|
|
||||||
|
const PEPE = new Token(
|
||||||
|
ETHEREUM_CHAIN_ID,
|
||||||
|
'0x6982508145454Ce325dDbE47a25d4ec3d2311933',
|
||||||
|
18,
|
||||||
|
'PEPE',
|
||||||
|
'Pepe'
|
||||||
|
);
|
||||||
|
|
||||||
|
const STETH = new Token(ETHEREUM_CHAIN_ID, '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', 18, 'stETH', 'Lido Staked Ether');
|
||||||
|
const SHIB = new Token(ETHEREUM_CHAIN_ID, '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', 18, 'SHIB', 'Shiba Inu');
|
||||||
|
const LINK = new Token(ETHEREUM_CHAIN_ID, '0x514910771AF9Ca656af840dff83E8264EcF986CA', 18, 'LINK', 'Chainlink');
|
||||||
|
const POL_TOKEN = new Token(ETHEREUM_CHAIN_ID, '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6', 18, 'POL', 'Polygon');
|
||||||
|
const WLFI = new Token(ETHEREUM_CHAIN_ID, '0x66f85E3865D0cFDC009acf6280a8621f12e46CCf', 18, 'WLFI', 'World Liberty Financial');
|
||||||
|
const AAVE_TOKEN = new Token(ETHEREUM_CHAIN_ID, '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', 18, 'AAVE', 'Aave');
|
||||||
|
|
||||||
|
export const SWAP_TOKENS: Record<SwapTokenSymbol, SwapTokenConfig> = {
|
||||||
|
ETH: {
|
||||||
|
symbol: 'ETH',
|
||||||
|
address: 'native',
|
||||||
|
decimals: 18,
|
||||||
|
isNative: true,
|
||||||
|
currency: Ether.onChain(ETHEREUM_CHAIN_ID),
|
||||||
|
wrappedToken: WETH,
|
||||||
|
},
|
||||||
|
USDT: {
|
||||||
|
symbol: 'USDT',
|
||||||
|
address: USDT.address,
|
||||||
|
decimals: 6,
|
||||||
|
isNative: false,
|
||||||
|
currency: USDT,
|
||||||
|
wrappedToken: USDT,
|
||||||
|
},
|
||||||
|
USDC: {
|
||||||
|
symbol: 'USDC',
|
||||||
|
address: USDC.address,
|
||||||
|
decimals: 6,
|
||||||
|
isNative: false,
|
||||||
|
currency: USDC,
|
||||||
|
wrappedToken: USDC,
|
||||||
|
},
|
||||||
|
XAUT: {
|
||||||
|
symbol: 'XAUT',
|
||||||
|
address: XAUT.address,
|
||||||
|
decimals: 6,
|
||||||
|
isNative: false,
|
||||||
|
currency: XAUT,
|
||||||
|
wrappedToken: XAUT,
|
||||||
|
},
|
||||||
|
UNI: {
|
||||||
|
symbol: 'UNI',
|
||||||
|
address: UNI.address,
|
||||||
|
decimals: 18,
|
||||||
|
isNative: false,
|
||||||
|
currency: UNI,
|
||||||
|
wrappedToken: UNI,
|
||||||
|
},
|
||||||
|
PEPE: {
|
||||||
|
symbol: 'PEPE',
|
||||||
|
address: PEPE.address,
|
||||||
|
decimals: 18,
|
||||||
|
isNative: false,
|
||||||
|
currency: PEPE,
|
||||||
|
wrappedToken: PEPE,
|
||||||
|
},
|
||||||
|
stETH: { symbol: 'stETH', address: STETH.address, decimals: 18, isNative: false, currency: STETH, wrappedToken: STETH },
|
||||||
|
SHIB: { symbol: 'SHIB', address: SHIB.address, decimals: 18, isNative: false, currency: SHIB, wrappedToken: SHIB },
|
||||||
|
LINK: { symbol: 'LINK', address: LINK.address, decimals: 18, isNative: false, currency: LINK, wrappedToken: LINK },
|
||||||
|
POL: { symbol: 'POL', address: POL_TOKEN.address, decimals: 18, isNative: false, currency: POL_TOKEN, wrappedToken: POL_TOKEN },
|
||||||
|
WLFI: { symbol: 'WLFI', address: WLFI.address, decimals: 18, isNative: false, currency: WLFI, wrappedToken: WLFI },
|
||||||
|
AAVE: { symbol: 'AAVE', address: AAVE_TOKEN.address, decimals: 18, isNative: false, currency: AAVE_TOKEN, wrappedToken: AAVE_TOKEN },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SWAP_TOKEN_OPTIONS: SwapTokenSymbol[] = ['ETH', 'USDT', 'USDC', 'XAUT', 'UNI', 'PEPE', 'stETH', 'SHIB', 'LINK', 'POL', 'WLFI', 'AAVE'];
|
||||||
|
|
||||||
|
// ─── Multi-chain swap support ───
|
||||||
|
|
||||||
|
export type SwapChain = 'ETH' | 'SOL' | 'TRX' | 'BSC';
|
||||||
|
|
||||||
|
export const SOL_TOKEN_MINTS: Record<string, string> = {
|
||||||
|
SOL: 'So11111111111111111111111111111111111111112',
|
||||||
|
USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
|
||||||
|
USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
|
||||||
|
PUMP: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn',
|
||||||
|
JUP: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN',
|
||||||
|
WIF: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm',
|
||||||
|
POPCAT: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr',
|
||||||
|
TRUMP: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN',
|
||||||
|
PYTH: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3',
|
||||||
|
JTO: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL',
|
||||||
|
W: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ',
|
||||||
|
BONK: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
|
||||||
|
ORCA: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE',
|
||||||
|
PENGU: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv',
|
||||||
|
RAY: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOL_TOKEN_DECIMALS: Record<string, number> = {
|
||||||
|
SOL: 9,
|
||||||
|
USDT: 6,
|
||||||
|
USDC: 6,
|
||||||
|
PUMP: 6,
|
||||||
|
JUP: 6,
|
||||||
|
WIF: 6,
|
||||||
|
POPCAT: 9,
|
||||||
|
TRUMP: 6,
|
||||||
|
PYTH: 6,
|
||||||
|
JTO: 9,
|
||||||
|
W: 6,
|
||||||
|
BONK: 5,
|
||||||
|
ORCA: 6,
|
||||||
|
PENGU: 6,
|
||||||
|
RAY: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRX_TOKEN_DECIMALS: Record<string, number> = {
|
||||||
|
TRX: 6,
|
||||||
|
USDT: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── BSC platform fee (0.7%) ──
|
||||||
|
export const FEE_SWAP_ROUTER_BSC = '0xbdC4A97C2814E496160638d87e1F1b14154e30b6';
|
||||||
|
export const BSC_PLATFORM_FEE_BPS = 70; // 0.7% — matches deployed FeeSwapRouter_BSC
|
||||||
|
export const PANCAKE_SMART_ROUTER_BSC = '0x13f4EA83D0bd40E75C8222255bc855a974568Dd4';
|
||||||
|
export const WBNB_ADDRESS = '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c';
|
||||||
|
|
||||||
|
export const BSC_TOKEN_ADDRESSES: Record<string, string> = {
|
||||||
|
BNB: 'native',
|
||||||
|
USDT: '0x55d398326f99059fF775485246999027B3197955',
|
||||||
|
DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BSC_TOKEN_DECIMALS: Record<string, number> = {
|
||||||
|
BNB: 18,
|
||||||
|
USDT: 18,
|
||||||
|
DOGE: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SWAP_TOKEN_OPTIONS_BY_CHAIN: Record<SwapChain, string[]> = {
|
||||||
|
ETH: ['ETH', 'USDT', 'USDC', 'XAUT', 'UNI', 'PEPE', 'stETH', 'SHIB', 'LINK', 'POL', 'WLFI', 'AAVE'],
|
||||||
|
SOL: ['SOL', 'USDT', 'USDC', 'PUMP', 'JUP', 'WIF', 'POPCAT', 'TRUMP', 'PYTH', 'JTO', 'W', 'BONK', 'ORCA', 'PENGU', 'RAY'],
|
||||||
|
TRX: ['TRX', 'USDT'],
|
||||||
|
BSC: ['BNB', 'USDT', 'DOGE'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHAIN_DEFAULT_TOKENS: Record<SwapChain, { from: string; to: string }> = {
|
||||||
|
ETH: { from: 'ETH', to: 'USDT' },
|
||||||
|
SOL: { from: 'SOL', to: 'USDT' },
|
||||||
|
TRX: { from: 'TRX', to: 'USDT' },
|
||||||
|
BSC: { from: 'BNB', to: 'USDT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSlippageBpsForChain(chain: SwapChain, fromSymbol: string, toSymbol: string): number {
|
||||||
|
if (chain === 'ETH') return getSlippageBps(fromSymbol as SwapTokenSymbol, toSymbol as SwapTokenSymbol);
|
||||||
|
|
||||||
|
// SOL
|
||||||
|
if (chain === 'SOL') {
|
||||||
|
const stablePair = (fromSymbol === 'USDT' && toSymbol === 'USDC') || (fromSymbol === 'USDC' && toSymbol === 'USDT');
|
||||||
|
if (stablePair) return 10; // 0.10%
|
||||||
|
return 50; // 0.50%
|
||||||
|
}
|
||||||
|
|
||||||
|
// BSC — PancakeSwap V2
|
||||||
|
if (chain === 'BSC') return 50; // 0.50%
|
||||||
|
|
||||||
|
// TRX — lower liquidity
|
||||||
|
return 100; // 1.00%
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExplorerTxUrl(chain: SwapChain, txHash: string): string {
|
||||||
|
switch (chain) {
|
||||||
|
case 'ETH': return `https://etherscan.io/tx/${txHash}`;
|
||||||
|
case 'SOL': return `https://solscan.io/tx/${txHash}`;
|
||||||
|
case 'TRX': return `https://tronscan.org/#/transaction/${txHash}`;
|
||||||
|
case 'BSC': return `https://bscscan.com/tx/${txHash}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const V3_POOL_CANDIDATES: PoolCandidate[] = [
|
||||||
|
{ tokenA: WETH, tokenB: USDT, fee: FeeAmount.LOW },
|
||||||
|
{ tokenA: WETH, tokenB: USDT, fee: FeeAmount.MEDIUM },
|
||||||
|
{ tokenA: WETH, tokenB: USDC, fee: FeeAmount.LOW },
|
||||||
|
{ tokenA: WETH, tokenB: USDC, fee: FeeAmount.MEDIUM },
|
||||||
|
{ tokenA: USDT, tokenB: USDC, fee: FeeAmount.LOWEST },
|
||||||
|
{ tokenA: USDT, tokenB: USDC, fee: FeeAmount.LOW },
|
||||||
|
// XAUT pairs
|
||||||
|
{ tokenA: WETH, tokenB: XAUT, fee: FeeAmount.MEDIUM },
|
||||||
|
{ tokenA: WETH, tokenB: XAUT, fee: FeeAmount.HIGH },
|
||||||
|
{ tokenA: XAUT, tokenB: USDT, fee: FeeAmount.MEDIUM },
|
||||||
|
// UNI pairs
|
||||||
|
{ tokenA: WETH, tokenB: UNI, fee: FeeAmount.MEDIUM },
|
||||||
|
{ tokenA: WETH, tokenB: UNI, fee: FeeAmount.LOW },
|
||||||
|
{ tokenA: UNI, tokenB: USDT, fee: FeeAmount.MEDIUM },
|
||||||
|
// PEPE pairs
|
||||||
|
{ tokenA: WETH, tokenB: PEPE, fee: FeeAmount.MEDIUM },
|
||||||
|
{ tokenA: WETH, tokenB: PEPE, fee: FeeAmount.HIGH },
|
||||||
|
// stETH pairs
|
||||||
|
{ tokenA: WETH, tokenB: STETH, fee: FeeAmount.LOW },
|
||||||
|
{ tokenA: WETH, tokenB: STETH, fee: FeeAmount.MEDIUM },
|
||||||
|
// SHIB pairs
|
||||||
|
{ tokenA: WETH, tokenB: SHIB, fee: FeeAmount.MEDIUM },
|
||||||
|
{ tokenA: WETH, tokenB: SHIB, fee: FeeAmount.HIGH },
|
||||||
|
// LINK pairs
|
||||||
|
{ tokenA: WETH, tokenB: LINK, fee: FeeAmount.LOW },
|
||||||
|
{ tokenA: WETH, tokenB: LINK, fee: FeeAmount.MEDIUM },
|
||||||
|
// POL pairs
|
||||||
|
{ tokenA: WETH, tokenB: POL_TOKEN, fee: FeeAmount.MEDIUM },
|
||||||
|
{ tokenA: WETH, tokenB: POL_TOKEN, fee: FeeAmount.HIGH },
|
||||||
|
// WLFI pairs
|
||||||
|
{ tokenA: WETH, tokenB: WLFI, fee: FeeAmount.MEDIUM },
|
||||||
|
{ tokenA: WETH, tokenB: WLFI, fee: FeeAmount.HIGH },
|
||||||
|
// AAVE pairs
|
||||||
|
{ tokenA: WETH, tokenB: AAVE_TOKEN, fee: FeeAmount.LOW },
|
||||||
|
{ tokenA: WETH, tokenB: AAVE_TOKEN, fee: FeeAmount.MEDIUM },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_SLIPPAGE_BPS = 10;
|
||||||
|
export const DEFAULT_DEADLINE_SECONDS = 60 * 20;
|
||||||
|
export const SWAP_REQUEST_TIMEOUT_MS = 12_000;
|
||||||
|
|
||||||
|
/** V4 StateView for reading pool state offchain */
|
||||||
|
export const STATE_VIEW_ADDRESS = '0x7ffe42c4a5deea5b0fec41c94c136cf115597227';
|
||||||
|
|
||||||
|
/** V4 pool params: fee (bps), tickSpacing. Hooks = zero for standard pools. */
|
||||||
|
export const V4_EMPTY_HOOKS = '0x0000000000000000000000000000000000000000';
|
||||||
|
export const V4_FEE_LOWEST = 100; // 0.01% - stablecoins
|
||||||
|
export const V4_FEE_LOW = 500; // 0.05%
|
||||||
|
export const V4_FEE_MEDIUM = 3000; // 0.30%
|
||||||
|
export const V4_TICK_SPACING_1 = 1;
|
||||||
|
export const V4_TICK_SPACING_10 = 10;
|
||||||
|
export const V4_TICK_SPACING_60 = 60;
|
||||||
|
|
||||||
|
/** V4 pool key candidates for ETH/USDT, ETH/USDC, USDT/USDC */
|
||||||
|
export const V4_POOL_KEY_CANDIDATES: Array<{
|
||||||
|
currencyA: Token | ReturnType<typeof Ether.onChain>;
|
||||||
|
currencyB: Token | ReturnType<typeof Ether.onChain>;
|
||||||
|
fee: number;
|
||||||
|
tickSpacing: number;
|
||||||
|
}> = [
|
||||||
|
{ currencyA: WETH, currencyB: USDT, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
|
||||||
|
{ currencyA: WETH, currencyB: USDT, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
|
||||||
|
{ currencyA: WETH, currencyB: USDC, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
|
||||||
|
{ currencyA: WETH, currencyB: USDC, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
|
||||||
|
{ currencyA: USDT, currencyB: USDC, fee: V4_FEE_LOWEST, tickSpacing: V4_TICK_SPACING_1 },
|
||||||
|
{ currencyA: USDT, currencyB: USDC, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
|
||||||
|
// XAUT pairs
|
||||||
|
{ currencyA: WETH, currencyB: XAUT, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
|
||||||
|
// UNI pairs
|
||||||
|
{ currencyA: WETH, currencyB: UNI, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
|
||||||
|
{ currencyA: WETH, currencyB: UNI, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
|
||||||
|
// PEPE pairs
|
||||||
|
{ currencyA: WETH, currencyB: PEPE, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
|
||||||
|
// stETH
|
||||||
|
{ currencyA: WETH, currencyB: STETH, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
|
||||||
|
// SHIB
|
||||||
|
{ currencyA: WETH, currencyB: SHIB, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
|
||||||
|
// LINK
|
||||||
|
{ currencyA: WETH, currencyB: LINK, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
|
||||||
|
{ currencyA: WETH, currencyB: LINK, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
|
||||||
|
// POL
|
||||||
|
{ currencyA: WETH, currencyB: POL_TOKEN, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
|
||||||
|
// WLFI
|
||||||
|
{ currencyA: WETH, currencyB: WLFI, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
|
||||||
|
// AAVE
|
||||||
|
{ currencyA: WETH, currencyB: AAVE_TOKEN, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getSlippageBps(fromSymbol: SwapTokenSymbol, toSymbol: SwapTokenSymbol): number {
|
||||||
|
const stablePair =
|
||||||
|
(fromSymbol === 'USDT' && toSymbol === 'USDC') || (fromSymbol === 'USDC' && toSymbol === 'USDT');
|
||||||
|
if (stablePair) return 1; // 0.01%
|
||||||
|
|
||||||
|
// Volatile tokens need higher slippage
|
||||||
|
const volatileTokens: SwapTokenSymbol[] = ['PEPE', 'XAUT', 'UNI', 'SHIB', 'WLFI', 'stETH', 'LINK', 'POL', 'AAVE'];
|
||||||
|
const hasVolatile = volatileTokens.includes(fromSymbol) || volatileTokens.includes(toSymbol);
|
||||||
|
if (hasVolatile) return 50; // 0.50%
|
||||||
|
|
||||||
|
const hasStable =
|
||||||
|
fromSymbol === 'USDT' || fromSymbol === 'USDC' || toSymbol === 'USDT' || toSymbol === 'USDC';
|
||||||
|
if (hasStable) return 5; // 0.05%
|
||||||
|
return 10; // 0.10%
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSwapToken(symbol: SwapTokenSymbol): SwapTokenConfig {
|
||||||
|
return SWAP_TOKENS[symbol];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isErc20SwapToken(symbol: SwapTokenSymbol): boolean {
|
||||||
|
return !SWAP_TOKENS[symbol].isNative;
|
||||||
|
}
|
||||||
23
apps/web/src/lib/swap/errors.ts
Normal file
23
apps/web/src/lib/swap/errors.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { SwapChain } from './constants';
|
||||||
|
|
||||||
|
export function mapSwapError(chain: SwapChain, error: unknown): string {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
// Jupiter / SOL
|
||||||
|
if (msg.includes('INSUFFICIENT_FUNDS') || msg.includes('InsufficientFunds')) return 'Insufficient SOL balance';
|
||||||
|
if (msg.includes('SlippageToleranceExceeded') || msg.includes('Slippage')) return 'Slippage exceeded — try increasing tolerance';
|
||||||
|
|
||||||
|
// SunSwap / TRX
|
||||||
|
if (msg.includes('BANDWIDTH_ERROR') || msg.includes('bandwidth')) return 'Insufficient bandwidth — need to freeze TRX';
|
||||||
|
if (msg.includes('ENERGY_ERROR') || msg.includes('energy')) return 'Insufficient energy — need TRX for gas';
|
||||||
|
if (msg.includes('CONTRACT_VALIDATE_ERROR')) return 'Transaction validation failed on TRON';
|
||||||
|
if (msg.includes('balance is not sufficient')) return 'Insufficient token balance';
|
||||||
|
|
||||||
|
// Generic
|
||||||
|
if (msg.includes('timeout') || msg.includes('timed out')) return 'Request timed out — please try again';
|
||||||
|
if (msg.includes('429') || msg.includes('rate')) return 'Rate limited — please wait and retry';
|
||||||
|
if (msg.includes('No route') || msg.includes('No Uniswap route')) return 'No swap route found for this pair';
|
||||||
|
if (msg.includes('not allowed') || msg.includes('403')) return 'API access restricted';
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
131
apps/web/src/lib/swap/execute.ts
Normal file
131
apps/web/src/lib/swap/execute.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import { Percent } from '@uniswap/sdk-core';
|
||||||
|
import { SwapRouter, TokenTransferMode } from '@uniswap/universal-router-sdk';
|
||||||
|
import { createEthProvider } from '@/lib/eth-provider';
|
||||||
|
import {
|
||||||
|
DEFAULT_DEADLINE_SECONDS,
|
||||||
|
ETHEREUM_CHAIN_ID,
|
||||||
|
FEE_RECIPIENT,
|
||||||
|
FEE_SWAP_ROUTER_ETH,
|
||||||
|
PLATFORM_FEE_BPS,
|
||||||
|
SWAP_PROXY_ADDRESS_MAINNET,
|
||||||
|
UNIVERSAL_ROUTER_ADDRESS_MAINNET,
|
||||||
|
UNIVERSAL_ROUTER_VERSION,
|
||||||
|
getSwapToken,
|
||||||
|
type SwapQuoteRequest,
|
||||||
|
} from './constants';
|
||||||
|
import type { SwapQuoteResult } from './quote';
|
||||||
|
|
||||||
|
const provider = createEthProvider();
|
||||||
|
|
||||||
|
export interface ExecuteSwapParams {
|
||||||
|
privateKey: string;
|
||||||
|
request: SwapQuoteRequest;
|
||||||
|
quote: SwapQuoteResult;
|
||||||
|
maxFeeGwei?: string | null;
|
||||||
|
priorityFeeGwei?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteSwapResult {
|
||||||
|
hash: string;
|
||||||
|
explorerUrl: string;
|
||||||
|
submittedTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeSwap(params: ExecuteSwapParams): Promise<ExecuteSwapResult> {
|
||||||
|
const wallet = new ethers.Wallet(params.privateKey, provider);
|
||||||
|
const inputToken = getSwapToken(params.request.fromSymbol);
|
||||||
|
const slippageTolerance = new Percent(params.request.slippageBps, 10_000);
|
||||||
|
const deadline = Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS;
|
||||||
|
|
||||||
|
const routerOptions = {
|
||||||
|
recipient: wallet.address,
|
||||||
|
slippageTolerance,
|
||||||
|
deadlineOrPreviousBlockhash: deadline,
|
||||||
|
urVersion: UNIVERSAL_ROUTER_VERSION,
|
||||||
|
...(inputToken.isNative
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
tokenTransferMode: TokenTransferMode.ApproveProxy,
|
||||||
|
chainId: ETHEREUM_CHAIN_ID,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const methodParameters = SwapRouter.swapCallParameters(params.quote.trade, routerOptions);
|
||||||
|
const feeOverrides = await getFeeOverrides(params.maxFeeGwei, params.priorityFeeGwei);
|
||||||
|
|
||||||
|
let response: ethers.providers.TransactionResponse;
|
||||||
|
let submittedTo: string;
|
||||||
|
|
||||||
|
if (inputToken.isNative) {
|
||||||
|
// ── Native ETH → Token: route through FeeSwapRouter contract ──
|
||||||
|
// Quote was built for 99.3% of user's amount. We send the full original
|
||||||
|
// amount to the contract — it takes 0.7% fee and forwards 99.3% + calldata
|
||||||
|
// to the Universal Router.
|
||||||
|
const fullAmountRaw = ethers.utils.parseUnits(params.request.amount, 18);
|
||||||
|
const feeRouterIface = new ethers.utils.Interface([
|
||||||
|
'function swapNativeWithFee(bytes calldata routerCalldata) external payable',
|
||||||
|
]);
|
||||||
|
const data = feeRouterIface.encodeFunctionData('swapNativeWithFee', [
|
||||||
|
methodParameters.calldata,
|
||||||
|
]);
|
||||||
|
|
||||||
|
submittedTo = FEE_SWAP_ROUTER_ETH;
|
||||||
|
response = await wallet.sendTransaction({
|
||||||
|
to: FEE_SWAP_ROUTER_ETH,
|
||||||
|
data,
|
||||||
|
value: fullAmountRaw,
|
||||||
|
...feeOverrides,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// ── ERC20 → Token: send 0.7% fee separately, then swap 99.3% normally ──
|
||||||
|
const token = getSwapToken(params.request.fromSymbol);
|
||||||
|
const fullAmountRaw = ethers.utils.parseUnits(params.request.amount, token.decimals);
|
||||||
|
const feeAmount = fullAmountRaw.mul(PLATFORM_FEE_BPS).div(10000);
|
||||||
|
|
||||||
|
// Send 0.7% fee to fee wallet
|
||||||
|
const tokenContract = new ethers.Contract(
|
||||||
|
token.address,
|
||||||
|
['function transfer(address to, uint256 amount) returns (bool)'],
|
||||||
|
wallet,
|
||||||
|
);
|
||||||
|
const feeTx = await tokenContract.transfer(FEE_RECIPIENT, feeAmount, feeOverrides);
|
||||||
|
await feeTx.wait();
|
||||||
|
|
||||||
|
// Execute swap with 99% (calldata already built for adjusted amount)
|
||||||
|
submittedTo = SWAP_PROXY_ADDRESS_MAINNET;
|
||||||
|
response = await wallet.sendTransaction({
|
||||||
|
to: submittedTo,
|
||||||
|
data: methodParameters.calldata,
|
||||||
|
value: methodParameters.value,
|
||||||
|
...feeOverrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const receipt = await response.wait();
|
||||||
|
if (!receipt || receipt.status !== 1) {
|
||||||
|
throw new Error('Swap transaction reverted');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: response.hash,
|
||||||
|
explorerUrl: `https://etherscan.io/tx/${response.hash}`,
|
||||||
|
submittedTo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFeeOverrides(maxFeeGwei?: string | null, priorityFeeGwei?: string | null) {
|
||||||
|
if (maxFeeGwei?.trim()) {
|
||||||
|
return {
|
||||||
|
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
|
||||||
|
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const feeData = await provider.getFeeData();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(feeData.maxFeePerGas ? { maxFeePerGas: feeData.maxFeePerGas } : {}),
|
||||||
|
...(feeData.maxPriorityFeePerGas ? { maxPriorityFeePerGas: feeData.maxPriorityFeePerGas } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
365
apps/web/src/lib/swap/quote.ts
Normal file
365
apps/web/src/lib/swap/quote.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import { ethers } from 'ethers';
|
||||||
|
import { CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core';
|
||||||
|
import { Pool, Route, TICK_SPACINGS, encodeRouteToPath } from '@uniswap/v3-sdk';
|
||||||
|
import { Trade as RouterTrade } from '@uniswap/router-sdk';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
import {
|
||||||
|
ETHEREUM_CHAIN_ID,
|
||||||
|
PLATFORM_FEE_BPS,
|
||||||
|
QUOTER_V2_ABI,
|
||||||
|
QUOTER_V2_ADDRESS,
|
||||||
|
SWAP_REQUEST_TIMEOUT_MS,
|
||||||
|
SWAP_TOKENS,
|
||||||
|
V3_POOL_CANDIDATES,
|
||||||
|
UNISWAP_V3_POOL_ABI,
|
||||||
|
getSlippageBps,
|
||||||
|
type PoolCandidate,
|
||||||
|
type SwapQuoteRequest,
|
||||||
|
type SwapTokenSymbol,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
/** TickDataProvider that fetches tick data from the Uniswap V3 pool contract */
|
||||||
|
function createContractTickDataProvider(
|
||||||
|
poolContract: ethers.Contract,
|
||||||
|
tickSpacing: number
|
||||||
|
): { getTick: (tick: number) => Promise<{ liquidityNet: string }>; nextInitializedTickWithinOneWord: (tick: number, lte: boolean, _tickSpacing: number) => Promise<[number, boolean]> } {
|
||||||
|
async function getTick(tick: number): Promise<{ liquidityNet: string }> {
|
||||||
|
const result = await poolContract.ticks(tick);
|
||||||
|
return { liquidityNet: result.liquidityNet.toString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextInitializedTickWithinOneWord(tick: number, lte: boolean, _tickSpacing: number): Promise<[number, boolean]> {
|
||||||
|
const spacing = tickSpacing;
|
||||||
|
let compressed = Math.trunc(tick / spacing);
|
||||||
|
if (tick < 0 && tick % spacing !== 0) compressed--;
|
||||||
|
const wordPos = Math.floor(compressed / 256);
|
||||||
|
const bitPos = ((compressed % 256) + 256) % 256;
|
||||||
|
const word = (await poolContract.tickBitmap(wordPos)) as ethers.BigNumber;
|
||||||
|
const w = BigInt(word.toString());
|
||||||
|
let masked: bigint;
|
||||||
|
let nextCompressed: number;
|
||||||
|
if (lte) {
|
||||||
|
const mask = (1n << BigInt(bitPos + 1)) - 1n;
|
||||||
|
masked = w & mask;
|
||||||
|
const initialized = masked !== 0n;
|
||||||
|
if (initialized) {
|
||||||
|
let msbPos = 0;
|
||||||
|
for (let i = 255; i >= 0; i--) {
|
||||||
|
if ((masked >> BigInt(i)) & 1n) {
|
||||||
|
msbPos = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextCompressed = compressed - (bitPos - msbPos);
|
||||||
|
} else {
|
||||||
|
nextCompressed = compressed - bitPos;
|
||||||
|
}
|
||||||
|
return [nextCompressed * spacing, masked !== 0n];
|
||||||
|
} else {
|
||||||
|
const nextWordPos = Math.floor((compressed + 1) / 256);
|
||||||
|
const nextBitPos = (((compressed + 1) % 256) + 256) % 256;
|
||||||
|
const nextWord = (await poolContract.tickBitmap(nextWordPos)) as ethers.BigNumber;
|
||||||
|
const nw = BigInt(nextWord.toString());
|
||||||
|
const mask = ~((1n << BigInt(nextBitPos)) - 1n);
|
||||||
|
masked = nw & mask;
|
||||||
|
const initialized = masked !== 0n;
|
||||||
|
if (initialized) {
|
||||||
|
let lsbPos = 0;
|
||||||
|
for (let i = 0; i <= 255; i++) {
|
||||||
|
if ((masked >> BigInt(i)) & 1n) {
|
||||||
|
lsbPos = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextCompressed = compressed + 1 + (lsbPos - nextBitPos);
|
||||||
|
} else {
|
||||||
|
nextCompressed = compressed + 1 + (255 - nextBitPos);
|
||||||
|
}
|
||||||
|
return [nextCompressed * spacing, initialized];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getTick, nextInitializedTickWithinOneWord };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ETH_RPC_CANDIDATES = [
|
||||||
|
...new Set([
|
||||||
|
webEnv.ethRpcUrl,
|
||||||
|
'https://ethereum-rpc.publicnode.com',
|
||||||
|
'https://rpc.ankr.com/eth',
|
||||||
|
'https://eth.llamarpc.com',
|
||||||
|
].filter(Boolean)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const poolCache = new Map<string, Pool>();
|
||||||
|
const poolCacheTimestamps = new Map<string, number>();
|
||||||
|
const POOL_CACHE_TTL_MS = 30_000; // 30 seconds
|
||||||
|
|
||||||
|
async function getHealthyProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
|
||||||
|
let lastError: unknown = new Error('No Ethereum RPC available');
|
||||||
|
for (const rpcUrl of ETH_RPC_CANDIDATES) {
|
||||||
|
if (!rpcUrl?.trim()) continue;
|
||||||
|
const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, ETHEREUM_CHAIN_ID);
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
provider.getBlockNumber(),
|
||||||
|
4_000,
|
||||||
|
'RPC health-check timed out'
|
||||||
|
);
|
||||||
|
return provider;
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwapQuoteResult {
|
||||||
|
trade: RouterTrade<any, any, TradeType.EXACT_INPUT>;
|
||||||
|
amountInRaw: string;
|
||||||
|
amountInFormatted: string;
|
||||||
|
amountOutRaw: string;
|
||||||
|
amountOutFormatted: string;
|
||||||
|
minimumAmountOutRaw: string;
|
||||||
|
minimumAmountOutFormatted: string;
|
||||||
|
executionPrice: string;
|
||||||
|
priceImpact: string;
|
||||||
|
routeSymbols: SwapTokenSymbol[];
|
||||||
|
routeFees: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the on-chain Quoter for each candidate pool and pick the best output.
|
||||||
|
* This replaces the fragile off-chain bestTradeExactIn simulation that fails
|
||||||
|
* with RATIO_CURRENT when tick data is stale.
|
||||||
|
*/
|
||||||
|
async function findBestRouteOnChain(
|
||||||
|
pools: Pool[],
|
||||||
|
inputToken: (typeof SWAP_TOKENS)[SwapTokenSymbol],
|
||||||
|
outputToken: (typeof SWAP_TOKENS)[SwapTokenSymbol],
|
||||||
|
amountInRaw: ethers.BigNumber,
|
||||||
|
quoter: ethers.Contract,
|
||||||
|
): Promise<{ bestPool: Pool; bestAmountOut: ethers.BigNumber; bestRoutePath: string }> {
|
||||||
|
const inputCurrency = inputToken.currency;
|
||||||
|
const outputCurrency = outputToken.currency;
|
||||||
|
|
||||||
|
// Filter pools that contain both input and output tokens
|
||||||
|
const relevantPools = pools.filter((pool) => {
|
||||||
|
const t0 = pool.token0.address.toLowerCase();
|
||||||
|
const t1 = pool.token1.address.toLowerCase();
|
||||||
|
const inAddr = (inputToken.wrappedToken?.address ?? inputToken.address).toLowerCase();
|
||||||
|
const outAddr = (outputToken.wrappedToken?.address ?? outputToken.address).toLowerCase();
|
||||||
|
return (t0 === inAddr || t1 === inAddr) && (t0 === outAddr || t1 === outAddr);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!relevantPools.length) {
|
||||||
|
throw new Error('No Uniswap pool available for this token pair');
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestPool: Pool | null = null;
|
||||||
|
let bestAmountOut = ethers.BigNumber.from(0);
|
||||||
|
let bestRoutePath = '';
|
||||||
|
|
||||||
|
for (const pool of relevantPools) {
|
||||||
|
try {
|
||||||
|
const route = new Route([pool], inputCurrency, outputCurrency);
|
||||||
|
const routePath = encodeRouteToPath(route, false);
|
||||||
|
const [amountOut] = await withTimeout(
|
||||||
|
quoter.callStatic.quoteExactInput(routePath, amountInRaw),
|
||||||
|
SWAP_REQUEST_TIMEOUT_MS,
|
||||||
|
'Quote timed out',
|
||||||
|
);
|
||||||
|
if (amountOut.gt(bestAmountOut)) {
|
||||||
|
bestPool = pool;
|
||||||
|
bestAmountOut = amountOut;
|
||||||
|
bestRoutePath = routePath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Pool doesn't have enough liquidity or RPC issue — skip it
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestPool) {
|
||||||
|
throw new Error('No Uniswap route found for this token pair');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bestPool, bestAmountOut, bestRoutePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSwapQuote(request: SwapQuoteRequest): Promise<SwapQuoteResult> {
|
||||||
|
validateSwapRequest(request);
|
||||||
|
|
||||||
|
const provider = await getHealthyProvider();
|
||||||
|
const inputToken = SWAP_TOKENS[request.fromSymbol];
|
||||||
|
const outputToken = SWAP_TOKENS[request.toSymbol];
|
||||||
|
const originalAmountInRaw = ethers.utils.parseUnits(request.amount, inputToken.decimals);
|
||||||
|
// Apply 0.7% platform fee — swap only 99.3% of input (BigNumber math, no float)
|
||||||
|
const amountInRaw = originalAmountInRaw.mul(10000 - PLATFORM_FEE_BPS).div(10000);
|
||||||
|
const amountIn = CurrencyAmount.fromRawAmount(inputToken.currency, amountInRaw.toString());
|
||||||
|
const pools = await loadCandidatePools(provider);
|
||||||
|
const quoter = new ethers.Contract(QUOTER_V2_ADDRESS, QUOTER_V2_ABI, provider);
|
||||||
|
|
||||||
|
// Find best route via on-chain Quoter (avoids RATIO_CURRENT from off-chain simulation)
|
||||||
|
const { bestPool, bestAmountOut, bestRoutePath } = await findBestRouteOnChain(
|
||||||
|
pools, inputToken, outputToken, amountInRaw, quoter,
|
||||||
|
);
|
||||||
|
|
||||||
|
const route = new Route([bestPool], inputToken.currency, outputToken.currency);
|
||||||
|
const outputAmount = CurrencyAmount.fromRawAmount(outputToken.currency, bestAmountOut.toString());
|
||||||
|
const routerTrade = new RouterTrade({
|
||||||
|
v3Routes: [{
|
||||||
|
routev3: route,
|
||||||
|
inputAmount: amountIn,
|
||||||
|
outputAmount,
|
||||||
|
}],
|
||||||
|
tradeType: TradeType.EXACT_INPUT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const slippageTolerance = new Percent(request.slippageBps, 10_000);
|
||||||
|
const minimumAmountOut = routerTrade.minimumAmountOut(slippageTolerance);
|
||||||
|
const outputDecimals = outputToken.decimals;
|
||||||
|
|
||||||
|
return {
|
||||||
|
trade: routerTrade,
|
||||||
|
amountInRaw: amountInRaw.toString(),
|
||||||
|
amountInFormatted: request.amount,
|
||||||
|
amountOutRaw: bestAmountOut.toString(),
|
||||||
|
amountOutFormatted: formatUnitsSafe(bestAmountOut.toString(), outputDecimals),
|
||||||
|
minimumAmountOutRaw: minimumAmountOut.quotient.toString(),
|
||||||
|
minimumAmountOutFormatted: formatUnitsSafe(minimumAmountOut.quotient.toString(), outputDecimals),
|
||||||
|
executionPrice: routerTrade.executionPrice.toSignificant(6),
|
||||||
|
priceImpact: routerTrade.priceImpact.toFixed(4),
|
||||||
|
routeSymbols: route.tokenPath.map((token) => tokenAddressToSymbol(token.address)),
|
||||||
|
routeFees: route.pools.map((pool) => pool.fee),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCandidatePools(provider: ethers.providers.StaticJsonRpcProvider): Promise<Pool[]> {
|
||||||
|
const settled = await Promise.allSettled(V3_POOL_CANDIDATES.map((c) => loadPool(c, provider)));
|
||||||
|
const pools = settled
|
||||||
|
.filter((result): result is PromiseFulfilledResult<Pool> => result.status === 'fulfilled')
|
||||||
|
.map((result) => result.value);
|
||||||
|
|
||||||
|
if (!pools.length) {
|
||||||
|
const errors = settled
|
||||||
|
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
|
||||||
|
.map((r) => r.reason?.message ?? String(r.reason));
|
||||||
|
const hasRpcIssue = errors.some(
|
||||||
|
(e) => e?.includes('Failed to fetch') || e?.includes('timeout') || e?.includes('ECONNREFUSED')
|
||||||
|
);
|
||||||
|
const hint = hasRpcIssue
|
||||||
|
? ' Check your connection or try a different RPC in NEXT_PUBLIC_ETH_RPC_URL.'
|
||||||
|
: '';
|
||||||
|
throw new Error(`No supported Uniswap pools are currently reachable.${hint}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pools;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPool(candidate: PoolCandidate, provider: ethers.providers.StaticJsonRpcProvider): Promise<Pool> {
|
||||||
|
const cacheKey = `${candidate.tokenA.address}-${candidate.tokenB.address}-${candidate.fee}`;
|
||||||
|
const cachedPool = poolCache.get(cacheKey);
|
||||||
|
const cachedAt = poolCacheTimestamps.get(cacheKey) ?? 0;
|
||||||
|
|
||||||
|
if (cachedPool && Date.now() - cachedAt < POOL_CACHE_TTL_MS) {
|
||||||
|
return cachedPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
const poolAddress = Pool.getAddress(candidate.tokenA, candidate.tokenB, candidate.fee);
|
||||||
|
const code = await withTimeout(
|
||||||
|
provider.getCode(poolAddress),
|
||||||
|
SWAP_REQUEST_TIMEOUT_MS,
|
||||||
|
'Pool discovery timed out'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!code || code === '0x') {
|
||||||
|
throw new Error(`Pool ${poolAddress} is unavailable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const poolContract = new ethers.Contract(poolAddress, UNISWAP_V3_POOL_ABI, provider);
|
||||||
|
const [liquidity, slot0] = await Promise.all([
|
||||||
|
withTimeout(poolContract.liquidity() as Promise<bigint>, SWAP_REQUEST_TIMEOUT_MS, 'Pool liquidity request timed out'),
|
||||||
|
withTimeout(
|
||||||
|
poolContract.slot0() as Promise<{ sqrtPriceX96: bigint; tick: number }>,
|
||||||
|
SWAP_REQUEST_TIMEOUT_MS,
|
||||||
|
'Pool slot0 request timed out'
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tickSpacing = TICK_SPACINGS[candidate.fee];
|
||||||
|
const tickDataProvider = createContractTickDataProvider(poolContract, tickSpacing);
|
||||||
|
const pool = new Pool(
|
||||||
|
candidate.tokenA,
|
||||||
|
candidate.tokenB,
|
||||||
|
candidate.fee,
|
||||||
|
slot0.sqrtPriceX96.toString(),
|
||||||
|
liquidity.toString(),
|
||||||
|
slot0.tick,
|
||||||
|
tickDataProvider
|
||||||
|
);
|
||||||
|
|
||||||
|
poolCache.set(cacheKey, pool);
|
||||||
|
poolCacheTimestamps.set(cacheKey, Date.now());
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSwapRequest(request: SwapQuoteRequest): void {
|
||||||
|
if (request.fromSymbol === request.toSymbol) {
|
||||||
|
throw new Error('Select two different tokens');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.amount || Number(request.amount) <= 0) {
|
||||||
|
throw new Error('Enter a valid swap amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(request.slippageBps) || request.slippageBps <= 0) {
|
||||||
|
throw new Error('Enter a valid slippage value');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenAddressToSymbol(address: string): SwapTokenSymbol {
|
||||||
|
const normalizedAddress = address.toLowerCase();
|
||||||
|
const matched = Object.values(SWAP_TOKENS).find((token) => {
|
||||||
|
if (token.address === 'native') {
|
||||||
|
return token.wrappedToken.address.toLowerCase() === normalizedAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token.address.toLowerCase() === normalizedAddress;
|
||||||
|
});
|
||||||
|
|
||||||
|
return matched?.symbol ?? 'ETH';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUnitsSafe(rawValue: bigint | string, decimals: number): string {
|
||||||
|
const bigintValue = typeof rawValue === 'bigint' ? rawValue : BigInt(rawValue);
|
||||||
|
if (bigintValue === 0n) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const divisor = 10n ** BigInt(decimals);
|
||||||
|
const whole = bigintValue / divisor;
|
||||||
|
const fraction = bigintValue % divisor;
|
||||||
|
|
||||||
|
if (fraction === 0n) {
|
||||||
|
return whole.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
|
||||||
|
promise
|
||||||
|
.then((value) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(value);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
65
apps/web/src/lib/swap/sol/execute.ts
Normal file
65
apps/web/src/lib/swap/sol/execute.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Connection, Keypair, VersionedTransaction } from '@solana/web3.js';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
|
||||||
|
export interface SolExecuteSwapParams {
|
||||||
|
privateKeyHex: string;
|
||||||
|
userPublicKey: string;
|
||||||
|
quoteResponse: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SolSwapResult {
|
||||||
|
hash: string;
|
||||||
|
explorerUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeSolSwap(params: SolExecuteSwapParams): Promise<SolSwapResult> {
|
||||||
|
const { privateKeyHex, userPublicKey, quoteResponse } = params;
|
||||||
|
|
||||||
|
// 1. Build swap transaction via backend proxy
|
||||||
|
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
|
||||||
|
const buildResponse = await fetch(`${apiUrl}/api/sol/swap/build`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ quoteResponse, userPublicKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!buildResponse.ok) {
|
||||||
|
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build swap' }));
|
||||||
|
throw new Error(body.error || `Swap build failed (${buildResponse.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { swapTransaction } = await buildResponse.json();
|
||||||
|
if (!swapTransaction) {
|
||||||
|
throw new Error('No swap transaction returned from Jupiter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Deserialize the VersionedTransaction
|
||||||
|
const txBuffer = Buffer.from(swapTransaction, 'base64');
|
||||||
|
const transaction = VersionedTransaction.deserialize(txBuffer);
|
||||||
|
|
||||||
|
// 3. Sign with user's Keypair
|
||||||
|
const keypair = Keypair.fromSecretKey(Buffer.from(privateKeyHex, 'hex'));
|
||||||
|
transaction.sign([keypair]);
|
||||||
|
|
||||||
|
// 4. Send to Solana RPC
|
||||||
|
const connection = new Connection(webEnv.solRpcUrl, 'confirmed');
|
||||||
|
const rawTx = transaction.serialize();
|
||||||
|
|
||||||
|
const signature = await connection.sendRawTransaction(rawTx, {
|
||||||
|
skipPreflight: false,
|
||||||
|
maxRetries: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Confirm transaction
|
||||||
|
const latestBlockhash = await connection.getLatestBlockhash();
|
||||||
|
await connection.confirmTransaction({
|
||||||
|
signature,
|
||||||
|
blockhash: latestBlockhash.blockhash,
|
||||||
|
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
|
||||||
|
}, 'confirmed');
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: signature,
|
||||||
|
explorerUrl: `https://solscan.io/tx/${signature}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
99
apps/web/src/lib/swap/sol/quote.ts
Normal file
99
apps/web/src/lib/swap/sol/quote.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { SOL_TOKEN_MINTS, SOL_TOKEN_DECIMALS } from '../constants';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
|
||||||
|
export interface SolSwapQuoteResult {
|
||||||
|
chain: 'SOL';
|
||||||
|
quoteResponse: Record<string, unknown>;
|
||||||
|
amountInRaw: string;
|
||||||
|
amountInFormatted: string;
|
||||||
|
amountOutRaw: string;
|
||||||
|
amountOutFormatted: string;
|
||||||
|
minimumAmountOutRaw: string;
|
||||||
|
minimumAmountOutFormatted: string;
|
||||||
|
priceImpact: string;
|
||||||
|
routeLabels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SolSwapQuoteRequest {
|
||||||
|
fromSymbol: string;
|
||||||
|
toSymbol: string;
|
||||||
|
amount: string;
|
||||||
|
slippageBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSolSwapQuote(request: SolSwapQuoteRequest): Promise<SolSwapQuoteResult> {
|
||||||
|
const inputMint = SOL_TOKEN_MINTS[request.fromSymbol];
|
||||||
|
const outputMint = SOL_TOKEN_MINTS[request.toSymbol];
|
||||||
|
|
||||||
|
if (!inputMint || !outputMint) {
|
||||||
|
throw new Error(`Unknown SOL token: ${request.fromSymbol} or ${request.toSymbol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromDecimals = SOL_TOKEN_DECIMALS[request.fromSymbol];
|
||||||
|
const toDecimals = SOL_TOKEN_DECIMALS[request.toSymbol];
|
||||||
|
|
||||||
|
// Convert human-readable amount to raw (lamports / smallest unit)
|
||||||
|
const amountRaw = parseUnitsToRaw(request.amount, fromDecimals);
|
||||||
|
|
||||||
|
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
|
||||||
|
const url = new URL(`${apiUrl}/api/sol/swap/quote`);
|
||||||
|
url.searchParams.set('inputMint', inputMint);
|
||||||
|
url.searchParams.set('outputMint', outputMint);
|
||||||
|
url.searchParams.set('amount', amountRaw);
|
||||||
|
url.searchParams.set('slippageBps', String(request.slippageBps));
|
||||||
|
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({ error: 'Jupiter API error' }));
|
||||||
|
throw new Error(body.error || `Jupiter quote failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Jupiter response fields
|
||||||
|
const outAmount = String(data.outAmount ?? '0');
|
||||||
|
const otherAmountThreshold = String(data.otherAmountThreshold ?? '0');
|
||||||
|
const priceImpactPct = String(data.priceImpactPct ?? '0');
|
||||||
|
|
||||||
|
// Extract route labels
|
||||||
|
const routeLabels: string[] = [];
|
||||||
|
if (Array.isArray(data.routePlan)) {
|
||||||
|
for (const step of data.routePlan) {
|
||||||
|
if (step.swapInfo?.label) routeLabels.push(step.swapInfo.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain: 'SOL',
|
||||||
|
quoteResponse: data,
|
||||||
|
amountInRaw: amountRaw,
|
||||||
|
amountInFormatted: request.amount,
|
||||||
|
amountOutRaw: outAmount,
|
||||||
|
amountOutFormatted: formatRawUnits(outAmount, toDecimals),
|
||||||
|
minimumAmountOutRaw: otherAmountThreshold,
|
||||||
|
minimumAmountOutFormatted: formatRawUnits(otherAmountThreshold, toDecimals),
|
||||||
|
priceImpact: priceImpactPct,
|
||||||
|
routeLabels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUnitsToRaw(amount: string, decimals: number): string {
|
||||||
|
const parts = amount.split('.');
|
||||||
|
const whole = parts[0] || '0';
|
||||||
|
let fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
|
||||||
|
const raw = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction);
|
||||||
|
return raw.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRawUnits(raw: string, decimals: number): string {
|
||||||
|
const value = BigInt(raw);
|
||||||
|
if (value === 0n) return '0';
|
||||||
|
|
||||||
|
const divisor = 10n ** BigInt(decimals);
|
||||||
|
const whole = value / divisor;
|
||||||
|
const fraction = value % divisor;
|
||||||
|
|
||||||
|
if (fraction === 0n) return whole.toString();
|
||||||
|
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
|
||||||
|
}
|
||||||
91
apps/web/src/lib/swap/trx/execute.ts
Normal file
91
apps/web/src/lib/swap/trx/execute.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { ethers, utils } from 'ethers';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
|
||||||
|
export interface TrxExecuteSwapParams {
|
||||||
|
privateKeyHex: string; // 32-byte secp256k1 key (hex, no 0x prefix)
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
amount: string; // raw amount in sun
|
||||||
|
amountOutMin: string; // raw minimum output
|
||||||
|
userAddress: string; // TRX base58 address
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrxSwapResult {
|
||||||
|
hash: string;
|
||||||
|
explorerUrl: string;
|
||||||
|
approvalHashes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeTrxSwap(params: TrxExecuteSwapParams): Promise<TrxSwapResult> {
|
||||||
|
const { privateKeyHex, from, to, amount, amountOutMin, userAddress } = params;
|
||||||
|
|
||||||
|
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
|
||||||
|
|
||||||
|
// 1. Build transaction(s) via backend
|
||||||
|
const buildResponse = await fetch(`${apiUrl}/api/tron/swap/build`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ from, to, amount, amountOutMin, userAddress }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!buildResponse.ok) {
|
||||||
|
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build TRX swap' }));
|
||||||
|
throw new Error(body.error || `TRX swap build failed (${buildResponse.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { transactions } = await buildResponse.json();
|
||||||
|
if (!transactions || !transactions.length) {
|
||||||
|
throw new Error('No transactions returned from TRX swap builder');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sign and broadcast each transaction in order
|
||||||
|
const signingKey = new utils.SigningKey('0x' + privateKeyHex);
|
||||||
|
const approvalHashes: string[] = [];
|
||||||
|
let swapHash = '';
|
||||||
|
|
||||||
|
for (const tx of transactions) {
|
||||||
|
// Sign the txID (which is SHA256 of raw_data)
|
||||||
|
const txID: string = tx.txID;
|
||||||
|
const digest = ethers.utils.arrayify('0x' + txID);
|
||||||
|
const signature = signingKey.signDigest(digest);
|
||||||
|
const sigHex = ethers.utils.joinSignature(signature).slice(2); // remove 0x, 65 bytes hex
|
||||||
|
|
||||||
|
// Add signature to transaction
|
||||||
|
const signedTx = {
|
||||||
|
...tx,
|
||||||
|
signature: [sigHex],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Broadcast
|
||||||
|
const broadcastResponse = await fetch(`${apiUrl}/api/tron/swap/broadcast`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ signedTransaction: signedTx }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await broadcastResponse.json();
|
||||||
|
|
||||||
|
if (!result.result) {
|
||||||
|
const errorMsg = result.message || result.code || 'Broadcast failed';
|
||||||
|
throw new Error(`TRX broadcast error: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tx.type === 'approve') {
|
||||||
|
approvalHashes.push(txID);
|
||||||
|
// Wait a bit for approval to be confirmed before swapping
|
||||||
|
await delay(3000);
|
||||||
|
} else {
|
||||||
|
swapHash = txID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: swapHash,
|
||||||
|
explorerUrl: `https://tronscan.org/#/transaction/${swapHash}`,
|
||||||
|
approvalHashes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
90
apps/web/src/lib/swap/trx/quote.ts
Normal file
90
apps/web/src/lib/swap/trx/quote.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { TRX_TOKEN_DECIMALS } from '../constants';
|
||||||
|
import { webEnv } from '@/lib/env';
|
||||||
|
|
||||||
|
export interface TrxSwapQuoteResult {
|
||||||
|
chain: 'TRX';
|
||||||
|
amountInRaw: string;
|
||||||
|
amountInFormatted: string;
|
||||||
|
amountOutRaw: string;
|
||||||
|
amountOutFormatted: string;
|
||||||
|
minimumAmountOutRaw: string;
|
||||||
|
minimumAmountOutFormatted: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrxSwapQuoteRequest {
|
||||||
|
fromSymbol: string;
|
||||||
|
toSymbol: string;
|
||||||
|
amount: string;
|
||||||
|
slippageBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrxSwapQuote(request: TrxSwapQuoteRequest): Promise<TrxSwapQuoteResult> {
|
||||||
|
const fromDecimals = TRX_TOKEN_DECIMALS[request.fromSymbol];
|
||||||
|
const toDecimals = TRX_TOKEN_DECIMALS[request.toSymbol];
|
||||||
|
|
||||||
|
if (fromDecimals === undefined || toDecimals === undefined) {
|
||||||
|
throw new Error(`Unknown TRX token: ${request.fromSymbol} or ${request.toSymbol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert human-readable amount to raw (sun / smallest unit)
|
||||||
|
const amountRaw = parseUnitsToRaw(request.amount, fromDecimals);
|
||||||
|
|
||||||
|
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
|
||||||
|
const url = new URL(`${apiUrl}/api/tron/swap/quote`);
|
||||||
|
url.searchParams.set('from', request.fromSymbol);
|
||||||
|
url.searchParams.set('to', request.toSymbol);
|
||||||
|
url.searchParams.set('amount', amountRaw);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({ error: 'TRX quote failed' }));
|
||||||
|
throw new Error(body.error || `TRX quote failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'TRX quote returned error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountOut = String(data.amountOut);
|
||||||
|
|
||||||
|
// Apply slippage to get minimum output
|
||||||
|
const amountOutBigInt = BigInt(amountOut);
|
||||||
|
const minOut = amountOutBigInt - (amountOutBigInt * BigInt(request.slippageBps) / 10000n);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain: 'TRX',
|
||||||
|
amountInRaw: amountRaw,
|
||||||
|
amountInFormatted: request.amount,
|
||||||
|
amountOutRaw: amountOut,
|
||||||
|
amountOutFormatted: formatRawUnits(amountOut, toDecimals),
|
||||||
|
minimumAmountOutRaw: minOut.toString(),
|
||||||
|
minimumAmountOutFormatted: formatRawUnits(minOut.toString(), toDecimals),
|
||||||
|
from: request.fromSymbol,
|
||||||
|
to: request.toSymbol,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUnitsToRaw(amount: string, decimals: number): string {
|
||||||
|
const parts = amount.split('.');
|
||||||
|
const whole = parts[0] || '0';
|
||||||
|
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
|
||||||
|
const raw = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction);
|
||||||
|
return raw.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRawUnits(raw: string, decimals: number): string {
|
||||||
|
const value = BigInt(raw);
|
||||||
|
if (value === 0n) return '0';
|
||||||
|
|
||||||
|
const divisor = 10n ** BigInt(decimals);
|
||||||
|
const whole = value / divisor;
|
||||||
|
const fraction = value % divisor;
|
||||||
|
|
||||||
|
if (fraction === 0n) return whole.toString();
|
||||||
|
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
|
||||||
|
}
|
||||||
48
apps/web/src/store/auth-store.ts
Normal file
48
apps/web/src/store/auth-store.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface Wallet {
|
||||||
|
chain: string;
|
||||||
|
address: string;
|
||||||
|
derivationPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: { id: string; email: string } | null;
|
||||||
|
wallets: Wallet[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
init: () => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
wallets: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
init: async () => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const wallets = await api.getWallets();
|
||||||
|
set({
|
||||||
|
user: { id: '', email: '' },
|
||||||
|
wallets,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
set({ user: null, wallets: [], loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
set({ user: null, wallets: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
114
apps/web/src/store/balance-store.ts
Normal file
114
apps/web/src/store/balance-store.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { DerivedWallet } from '@/lib/crypto/derive-keys';
|
||||||
|
import { fetchAllBalances } from '@/lib/balances';
|
||||||
|
import type { ChainBalance, PortfolioBalance } from '@/lib/balances/types';
|
||||||
|
|
||||||
|
interface BalanceState {
|
||||||
|
portfolio: PortfolioBalance | null;
|
||||||
|
loading: boolean;
|
||||||
|
refreshing: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchBalances: (wallets: DerivedWallet[]) => Promise<void>;
|
||||||
|
clearBalances: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBalanceStore = create<BalanceState>((set, get) => ({
|
||||||
|
portfolio: null,
|
||||||
|
loading: false,
|
||||||
|
refreshing: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchBalances: async (wallets) => {
|
||||||
|
if (!wallets.length) {
|
||||||
|
set({ portfolio: null, loading: false, refreshing: false, error: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPortfolio = !!get().portfolio;
|
||||||
|
set({
|
||||||
|
loading: !hasPortfolio,
|
||||||
|
refreshing: hasPortfolio,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fresh = await fetchAllBalances(wallets);
|
||||||
|
const prev = get().portfolio;
|
||||||
|
|
||||||
|
// Merge: if a chain had an error but we have previous data, keep previous
|
||||||
|
const portfolio = prev ? mergePortfolios(prev, fresh) : fresh;
|
||||||
|
|
||||||
|
set({
|
||||||
|
portfolio,
|
||||||
|
loading: false,
|
||||||
|
refreshing: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const prev = get().portfolio;
|
||||||
|
set({
|
||||||
|
portfolio: prev,
|
||||||
|
loading: false,
|
||||||
|
refreshing: false,
|
||||||
|
error: prev ? null : getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBalances: () => {
|
||||||
|
set({
|
||||||
|
portfolio: null,
|
||||||
|
loading: false,
|
||||||
|
refreshing: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a chain in fresh data has an error and all its token balances are zero,
|
||||||
|
* but previous data had real balances, keep the previous chain data.
|
||||||
|
*/
|
||||||
|
function mergePortfolios(prev: PortfolioBalance, fresh: PortfolioBalance): PortfolioBalance {
|
||||||
|
const chains = fresh.chains.map((freshChain) => {
|
||||||
|
if (!freshChain.error) return freshChain;
|
||||||
|
|
||||||
|
const prevChain = prev.chains.find((c) => c.chain === freshChain.chain);
|
||||||
|
if (!prevChain || prevChain.error) return freshChain;
|
||||||
|
|
||||||
|
// Chain has error and all balances are zero — keep previous
|
||||||
|
const allZero = freshChain.tokens.every((t) => t.balanceRaw === '0');
|
||||||
|
if (allZero) return prevChain;
|
||||||
|
|
||||||
|
return freshChain;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalUsd = sumNullable(chains.map((c) => c.totalUsd));
|
||||||
|
const errors: PortfolioBalance['errors'] = {};
|
||||||
|
for (const chain of chains) {
|
||||||
|
if (chain.error && chain.error !== '__transient__') errors[chain.chain] = chain.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chains,
|
||||||
|
totalUsd,
|
||||||
|
errors,
|
||||||
|
priceError: fresh.priceError,
|
||||||
|
updatedAt: fresh.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumNullable(values: Array<number | null>): number | null {
|
||||||
|
const filtered = values.filter((v): v is number => typeof v === 'number');
|
||||||
|
return filtered.length ? filtered.reduce((a, b) => a + b, 0) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unable to refresh balances';
|
||||||
|
}
|
||||||
34
apps/web/tsconfig.json
Normal file
34
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
240
contracts/DEPLOY.md
Normal file
240
contracts/DEPLOY.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# FeeSwapRouter — Инструкция по деплою
|
||||||
|
|
||||||
|
## Четыре сети — четыре подхода
|
||||||
|
|
||||||
|
| Сеть | Метод | DEX | Fee wallet | Комиссия | Нужен на газ |
|
||||||
|
|------|-------|-----|------------|----------|-------------|
|
||||||
|
| **Ethereum** (chainId 1) | Solidity контракт (Remix) | Uniswap Universal Router | `0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718` | ~~1%~~ → **0.7%** | ETH (~$5-20) |
|
||||||
|
| **BSC** (chainId 56) | Solidity контракт (Remix) | PancakeSwap V3 Smart Router | `0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718` | **0.7%** | BNB (~$0.50-1.00) |
|
||||||
|
| **TRON** | Solidity контракт (TronIDE) | SunSwap V2 Smart Router | `TYTfrem65362TFyQSARTheeYza1GQA37Ug` | **0.7%** | TRX (~50-150 TRX) |
|
||||||
|
| **Solana** | Jupiter Referral Program | Jupiter Aggregator | `Co43MKwqMRMCvhscVVrtQWvma87NEV7ba4cfo8cksgzJ` | **0.7%** | SOL (~0.01 SOL) |
|
||||||
|
|
||||||
|
Все адреса и проценты захардкожены — никаких параметров при деплое вводить НЕ НУЖНО.
|
||||||
|
|
||||||
|
**Важно**: ETH контракт уже задеплоен с 1% комиссией. Если нужно 0.7%, нужно задеплоить новый контракт.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Деплой FeeSwapRouter_ETH.sol (Ethereum)
|
||||||
|
|
||||||
|
**Статус: ✅ Задеплоен (1%) — `0xbdC4A97C2814E496160638d87e1F1b14154e30b6`**
|
||||||
|
**⚠️ Нужен передеплой с 0.7% если хочешь 0.7% on-chain**
|
||||||
|
|
||||||
|
### Что нужно
|
||||||
|
- MetaMask с ETH на балансе (на газ уйдёт ~$5-20)
|
||||||
|
- В MetaMask выбрана сеть **Ethereum Mainnet**
|
||||||
|
|
||||||
|
### Шаги
|
||||||
|
|
||||||
|
1. Открой https://remix.ethereum.org
|
||||||
|
2. Слева в File Explorer нажми **+** → создай файл `FeeSwapRouter_ETH.sol`
|
||||||
|
3. Вставь **ВЕСЬ** код из файла `contracts/FeeSwapRouter_ETH.sol`
|
||||||
|
4. Слева выбери вкладку **Solidity Compiler** (иконка с буквой S)
|
||||||
|
- Compiler: **0.8.20**
|
||||||
|
- **Enable optimization**: включи, runs: **200**
|
||||||
|
- Нажми **Compile FeeSwapRouter_ETH.sol**
|
||||||
|
- Должна появиться зелёная галочка (без ошибок)
|
||||||
|
5. Слева выбери вкладку **Deploy & Run Transactions** (иконка со стрелкой)
|
||||||
|
- Environment: **Injected Provider - MetaMask**
|
||||||
|
- Убедись что MetaMask на сети **Ethereum Mainnet**
|
||||||
|
- В выпадающем списке контрактов выбери **FeeSwapRouter_ETH**
|
||||||
|
- Нажми **Deploy**
|
||||||
|
- Подтверди транзакцию в MetaMask
|
||||||
|
6. После подтверждения — скопируй адрес контракта из консоли Remix
|
||||||
|
7. Сохрани адрес в `.env`:
|
||||||
|
```
|
||||||
|
FEE_SWAP_ROUTER_ETH=0x...твой_адрес...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Верификация на Etherscan
|
||||||
|
- Зайди на https://etherscan.io/verifyContract
|
||||||
|
- Адрес контракта: вставь адрес из шага 6
|
||||||
|
- Compiler: Solidity 0.8.20
|
||||||
|
- Optimization: Yes, 200 runs
|
||||||
|
- License: MIT
|
||||||
|
- Код: в Remix правой кнопкой на файл → **Flatten**, вставь результат
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Деплой FeeSwapRouter_BSC.sol (BSC)
|
||||||
|
|
||||||
|
### Что нужно
|
||||||
|
- MetaMask с BNB на балансе (на газ уйдёт ~$0.50-1.00)
|
||||||
|
- В MetaMask добавлена сеть **BNB Smart Chain**:
|
||||||
|
- RPC: `https://bsc-dataseed.binance.org`
|
||||||
|
- Chain ID: `56`
|
||||||
|
- Symbol: `BNB`
|
||||||
|
- Explorer: `https://bscscan.com`
|
||||||
|
|
||||||
|
### Шаги
|
||||||
|
|
||||||
|
1. Открой https://remix.ethereum.org
|
||||||
|
2. Создай файл `FeeSwapRouter_BSC.sol`
|
||||||
|
3. Вставь **ВЕСЬ** код из файла `contracts/FeeSwapRouter_BSC.sol`
|
||||||
|
4. Compiler → **0.8.20**, optimization ON (200 runs), Compile
|
||||||
|
5. Deploy → Injected Provider → MetaMask на сети **BNB Smart Chain**
|
||||||
|
6. Выбери контракт **FeeSwapRouter_BSC**, нажми **Deploy**, подтверди в MetaMask
|
||||||
|
7. Скопируй адрес, сохрани в `.env`:
|
||||||
|
```
|
||||||
|
FEE_SWAP_ROUTER_BSC=0x...твой_адрес...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Верификация на BscScan
|
||||||
|
- Зайди на https://bscscan.com/verifyContract
|
||||||
|
- Compiler: Solidity 0.8.20
|
||||||
|
- Optimization: Yes, 200 runs
|
||||||
|
- License: MIT
|
||||||
|
- Код: в Remix → Flatten → вставь результат
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Деплой FeeSwapRouter_TRX.sol (TRON)
|
||||||
|
|
||||||
|
**Статус: ✅ Задеплоен — `TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E`**
|
||||||
|
|
||||||
|
### Что нужно
|
||||||
|
- **TronLink** кошелёк (расширение для Chrome, аналог MetaMask для TRON)
|
||||||
|
- TRX на балансе (~50-150 TRX на газ)
|
||||||
|
- В TronLink выбрана сеть **TRON Mainnet**
|
||||||
|
|
||||||
|
### Шаги
|
||||||
|
|
||||||
|
1. Открой https://www.tronide.io (это Remix для TRON)
|
||||||
|
2. Слева в File Explorer нажми **+** → создай файл `FeeSwapRouter_TRX.sol`
|
||||||
|
3. Вставь **ВЕСЬ** код из файла `contracts/FeeSwapRouter_TRX.sol`
|
||||||
|
4. Compiler → **0.8.20**, optimization ON (200 runs), Compile
|
||||||
|
- ⚠️ TronIDE может не поддерживать OpenZeppelin импорты — но этот контракт
|
||||||
|
НЕ использует OpenZeppelin (ReentrancyGuard и Ownable реализованы вручную)
|
||||||
|
5. Deploy → Injected Provider → **TronLink** (должен быть установлен)
|
||||||
|
- Убедись что TronLink на сети **TRON Mainnet**
|
||||||
|
- В выпадающем списке выбери **FeeSwapRouter_TRX**
|
||||||
|
- Нажми **Deploy**, подтверди в TronLink
|
||||||
|
6. Скопируй адрес контракта (будет в формате T...)
|
||||||
|
7. Сохрани в `.env`:
|
||||||
|
```
|
||||||
|
FEE_SWAP_ROUTER_TRX=TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E
|
||||||
|
```
|
||||||
|
|
||||||
|
### Верификация на TronScan
|
||||||
|
- Зайди на https://tronscan.org/#/contracts/verify
|
||||||
|
- Вставь адрес контракта (T...)
|
||||||
|
- Compiler: 0.8.20
|
||||||
|
- Optimization: ON
|
||||||
|
- Код: скопируй весь контракт
|
||||||
|
|
||||||
|
### Адреса в контракте (TRON base58 → hex)
|
||||||
|
|
||||||
|
| Контракт | TRON адрес | Hex (в Solidity) |
|
||||||
|
|----------|-----------|-----------------|
|
||||||
|
| SunSwap V2 Router | `TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax` | `0x6e0617948fE030a7e4970f8389D4AD295F249b7e` |
|
||||||
|
| USDT (TRC-20) | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` | `0xa614f803b6fd780986a42c78ec9c7f77e6ded13c` |
|
||||||
|
| WTRX | `TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR` | `0x891cdb91d149f23b1a45d9c5ca78a88d0cb44c18` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Настройка комиссии на Solana (Jupiter Referral)
|
||||||
|
|
||||||
|
На Solana НЕ нужен смарт-контракт. Jupiter Aggregator **нативно поддерживает** платформенную комиссию через Referral Program.
|
||||||
|
|
||||||
|
### Что нужно
|
||||||
|
- Кошелёк Solana с ~0.01 SOL (на ренту и газ)
|
||||||
|
- Fee wallet: `Co43MKwqMRMCvhscVVrtQWvma87NEV7ba4cfo8cksgzJ`
|
||||||
|
|
||||||
|
### Шаги
|
||||||
|
|
||||||
|
#### Шаг 1: Создай Referral Account
|
||||||
|
|
||||||
|
1. Зайди на https://referral.jup.ag
|
||||||
|
2. Подключи кошелёк `Co43MKwqMRMCvhscVVrtQWvma87NEV7ba4cfo8cksgzJ`
|
||||||
|
3. Нажми **Create Referral Account**
|
||||||
|
4. Подтверди транзакцию (~0.003 SOL)
|
||||||
|
5. Скопируй **Referral Account Public Key** — это будет `JUPITER_REFERRAL_ACCOUNT`
|
||||||
|
|
||||||
|
#### Шаг 2: Создай Token Fee Accounts
|
||||||
|
|
||||||
|
Для каждого токена, с которого берётся комиссия, нужен отдельный fee token account:
|
||||||
|
|
||||||
|
1. На странице https://referral.jup.ag перейди в **Claim Token Accounts**
|
||||||
|
2. Нажми **Create Token Account** для каждого нужного токена:
|
||||||
|
- **SOL** (wrapped): `So11111111111111111111111111111111111111112`
|
||||||
|
- **USDT**: `Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB`
|
||||||
|
- **USDC**: `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`
|
||||||
|
3. Подтверди каждую транзакцию (~0.002 SOL каждая)
|
||||||
|
|
||||||
|
#### Шаг 3: Сохрани в `.env`
|
||||||
|
|
||||||
|
```
|
||||||
|
JUPITER_REFERRAL_ACCOUNT=...твой_referral_account_pubkey...
|
||||||
|
JUPITER_FEE_BPS=70
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Как это работает
|
||||||
|
|
||||||
|
Jupiter сам:
|
||||||
|
1. Считает 0.7% от суммы свапа
|
||||||
|
2. Отправляет комиссию на fee token account
|
||||||
|
3. Остальные 99.3% идут пользователю
|
||||||
|
|
||||||
|
Комиссия накапливается на token fee accounts. Можно клеймить (забирать) через https://referral.jup.ag → **Claim**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Bridge комиссия (0.7%)
|
||||||
|
|
||||||
|
Комиссия на бридж работает **off-chain** — перед мостом отправляется отдельная транзакция:
|
||||||
|
|
||||||
|
| Сеть | Как берётся fee | Fee wallet |
|
||||||
|
|------|----------------|------------|
|
||||||
|
| ETH → * | Отдельный ETH/ERC20 transfer перед бриджем | `0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718` |
|
||||||
|
| SOL → * | Отдельный SOL transfer перед бриджем | `Co43MKwqMRMCvhscVVrtQWvma87NEV7ba4cfo8cksgzJ` |
|
||||||
|
| TRX → * | Отдельный TRC20 transfer перед бриджем | `TYTfrem65362TFyQSARTheeYza1GQA37Ug` |
|
||||||
|
|
||||||
|
Контракт **НЕ нужен** для bridge fee — логика встроена в код кошелька (`apps/web/src/lib/bridge/execute.ts`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## После деплоя — интеграция с кошельком
|
||||||
|
|
||||||
|
После получения адресов контрактов:
|
||||||
|
|
||||||
|
1. Добавить адреса в `.env`:
|
||||||
|
```
|
||||||
|
FEE_SWAP_ROUTER_ETH=0xbdC4A97C2814E496160638d87e1F1b14154e30b6
|
||||||
|
FEE_SWAP_ROUTER_BSC=0xbdC4A97C2814E496160638d87e1F1b14154e30b6
|
||||||
|
FEE_SWAP_ROUTER_TRX=TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E
|
||||||
|
JUPITER_REFERRAL_ACCOUNT=...referral_pubkey...
|
||||||
|
JUPITER_FEE_BPS=70
|
||||||
|
```
|
||||||
|
2. ✅ BSC swap proxy — вызывает FeeSwapRouter_BSC
|
||||||
|
3. ✅ TRX swap proxy — вызывает FeeSwapRouter_TRX
|
||||||
|
4. Обновить SOL swap proxy — добавить `platformFeeBps=70` в Jupiter запросы
|
||||||
|
5. Approve токены должны идти на адрес FeeSwapRouter (не на DEX роутер)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Админ-функции (ETH, BSC, TRX контракты)
|
||||||
|
|
||||||
|
Кошелёк, с которого задеплоил — это **owner**. Доступные функции:
|
||||||
|
|
||||||
|
| Функция | Что делает |
|
||||||
|
|---------|-----------|
|
||||||
|
| `pause()` | Экстренная остановка всех свапов |
|
||||||
|
| `unpause()` | Возобновить свапы |
|
||||||
|
| `emergencyWithdrawNative()` | Вывести застрявший ETH/BNB/TRX |
|
||||||
|
| `emergencyWithdrawToken(address)` | Вывести застрявшие токены |
|
||||||
|
|
||||||
|
Вызывать через:
|
||||||
|
- **ETH**: Etherscan → Write Contract
|
||||||
|
- **BSC**: BscScan → Write Contract
|
||||||
|
- **TRX**: TronScan → Write Contract
|
||||||
|
|
||||||
|
**Важно**: комиссия (0.7%) и fee wallet захардкожены — их нельзя изменить. Это сделано специально для безопасности.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок деплоя (рекомендуемый)
|
||||||
|
|
||||||
|
1. ✅ ETH контракт (уже задеплоен, но с 1% — передеплоить если нужно 0.7%)
|
||||||
|
2. BSC контракт → через Remix + MetaMask
|
||||||
|
3. TRX контракт → через TronIDE + TronLink
|
||||||
|
4. SOL referral → через https://referral.jup.ag
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user