deploy: POST /api/wallets + full swagger
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import crypto from 'crypto';
|
||||
import { logger } from '../lib/logger';
|
||||
import { fetchVaultKV2 } from '../config/vault';
|
||||
|
||||
/**
|
||||
* CSRF token validation compatible with Python's `itsdangerous`
|
||||
@@ -18,57 +18,70 @@ import { logger } from '../lib/logger';
|
||||
|
||||
const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time
|
||||
|
||||
let csrfSecret: string | null = null;
|
||||
let csrfSalt = 'itsdangerous.Signer';
|
||||
let csrfDigest: 'sha1' | 'sha256' | 'sha512' = 'sha512';
|
||||
let csrfMaxAgeSec = 60 * 60 * 24 * 7; // 7 days
|
||||
export interface CsrfConfig {
|
||||
secret: string;
|
||||
salt: string;
|
||||
digest: 'sha1' | 'sha256' | 'sha512';
|
||||
maxAgeSec: number;
|
||||
}
|
||||
|
||||
export async function loadCsrfSecret(
|
||||
// Live config — атомарно подменяется через swapCsrfConfig()
|
||||
let current: CsrfConfig | null = null;
|
||||
|
||||
export function swapCsrfConfig(cfg: CsrfConfig | null): void {
|
||||
current = cfg;
|
||||
}
|
||||
|
||||
export function isCsrfConfigured(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект.
|
||||
*/
|
||||
export async function fetchCsrfConfig(
|
||||
addr: string,
|
||||
token: string,
|
||||
mount: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
const { fetchVaultKV2 } = await import('../config/vault');
|
||||
|
||||
): Promise<CsrfConfig> {
|
||||
const secrets = await fetchVaultKV2(addr, token, mount, path);
|
||||
if (!secrets) {
|
||||
logger.warn('Failed to load CSRF secret from Vault');
|
||||
return;
|
||||
throw new Error('Failed to load CSRF secret from Vault');
|
||||
}
|
||||
|
||||
const secret = secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret;
|
||||
if (!secret) {
|
||||
logger.warn('CSRF secret not found in Vault payload (expected key: secret_key)');
|
||||
return;
|
||||
const secret =
|
||||
secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret;
|
||||
if (!secret || typeof secret !== 'string' || secret.length < 32) {
|
||||
throw new Error('CSRF secret invalid: must be string >= 32 chars');
|
||||
}
|
||||
|
||||
csrfSecret = secret;
|
||||
if (secrets.salt) csrfSalt = secrets.salt;
|
||||
const salt = secrets.salt || 'itsdangerous.Signer';
|
||||
if (typeof salt !== 'string' || salt.length < 8) {
|
||||
throw new Error('CSRF salt invalid: must be string >= 8 chars');
|
||||
}
|
||||
|
||||
let digest: 'sha1' | 'sha256' | 'sha512' = 'sha512';
|
||||
if (secrets.digest === 'sha1' || secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
||||
csrfDigest = secrets.digest as 'sha1' | 'sha256' | 'sha512';
|
||||
digest = secrets.digest;
|
||||
}
|
||||
|
||||
let maxAgeSec = 60 * 60 * 24 * 7; // 7 days
|
||||
if (secrets.max_age_sec) {
|
||||
const n = parseInt(secrets.max_age_sec);
|
||||
if (!Number.isNaN(n) && n > 0) csrfMaxAgeSec = n;
|
||||
if (!Number.isNaN(n) && n > 0) maxAgeSec = n;
|
||||
}
|
||||
|
||||
logger.info(`CSRF secret loaded (digest=${csrfDigest}, salt="${csrfSalt}", max_age=${csrfMaxAgeSec}s)`);
|
||||
}
|
||||
|
||||
export function isCsrfConfigured(): boolean {
|
||||
return csrfSecret !== null;
|
||||
return { secret, salt, digest, maxAgeSec };
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): Buffer {
|
||||
// itsdangerous strips padding
|
||||
const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4);
|
||||
const padded = s + '='.repeat(pad);
|
||||
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
}
|
||||
|
||||
function deriveKey(secret: string, salt: string, digest: string): Buffer {
|
||||
// itsdangerous `Signer.derive_key`: HMAC(secret, salt + "signer")
|
||||
return crypto.createHmac(digest, secret).update(salt + 'signer').digest();
|
||||
}
|
||||
|
||||
@@ -85,13 +98,13 @@ export interface CsrfVerifyResult {
|
||||
}
|
||||
|
||||
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
if (!csrfSecret) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
|
||||
|
||||
const lastDot = token.lastIndexOf('.');
|
||||
if (lastDot < 0) return { valid: false, reason: 'Malformed token (no signature)' };
|
||||
|
||||
const payloadTs = token.slice(0, lastDot); // "<payload>.<timestamp>"
|
||||
const payloadTs = token.slice(0, lastDot);
|
||||
const sigStr = token.slice(lastDot + 1);
|
||||
|
||||
const prevDot = payloadTs.lastIndexOf('.');
|
||||
@@ -99,8 +112,8 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
|
||||
const tsStr = payloadTs.slice(prevDot + 1);
|
||||
|
||||
const derived = deriveKey(csrfSecret, csrfSalt, csrfDigest);
|
||||
const expectedSig = crypto.createHmac(csrfDigest, derived).update(payloadTs).digest();
|
||||
const derived = deriveKey(current.secret, current.salt, current.digest);
|
||||
const expectedSig = crypto.createHmac(current.digest, derived).update(payloadTs).digest();
|
||||
|
||||
let actualSig: Buffer;
|
||||
try {
|
||||
@@ -116,12 +129,11 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||
return { valid: false, reason: 'Signature mismatch' };
|
||||
}
|
||||
|
||||
// Timestamp check
|
||||
try {
|
||||
const issuedAt = decodeTimestamp(tsStr);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' };
|
||||
if (now - issuedAt > csrfMaxAgeSec) return { valid: false, reason: 'Token expired' };
|
||||
if (now - issuedAt > current.maxAgeSec) return { valid: false, reason: 'Token expired' };
|
||||
} catch {
|
||||
return { valid: false, reason: 'Invalid timestamp' };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as jose from 'jose';
|
||||
import { env } from '../config/env';
|
||||
import { fetchVaultKV2 } from '../config/vault';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
@@ -19,21 +20,41 @@ export interface AuthContext {
|
||||
token: AccessTokenPayload;
|
||||
}
|
||||
|
||||
const keyMap = new Map<string, Awaited<ReturnType<typeof jose.importSPKI>>>();
|
||||
type KeyType = Awaited<ReturnType<typeof jose.importSPKI>>;
|
||||
|
||||
export async function loadJwtKeysFromVault(
|
||||
// Whitelist надёжных асимметричных алгоритмов. Никогда не разрешаем 'none'/HS*
|
||||
// (HS — симметричные, могли бы быть подставлены через algorithm confusion).
|
||||
const ALLOWED_ALGORITHMS = new Set(['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA', 'PS256', 'PS384', 'PS512']);
|
||||
|
||||
if (!ALLOWED_ALGORITHMS.has(env.jwt.algorithm)) {
|
||||
throw new Error(`JWT_ALGORITHM "${env.jwt.algorithm}" not allowed. Use one of: ${[...ALLOWED_ALGORITHMS].join(', ')}`);
|
||||
}
|
||||
|
||||
// Live key store — атомарно подменяется через swapKeyMap()
|
||||
let keyMap: Map<string, KeyType> = new Map();
|
||||
|
||||
export function swapKeyMap(newMap: Map<string, KeyType>): void {
|
||||
keyMap = newMap;
|
||||
}
|
||||
|
||||
export function getKeyMapSize(): number {
|
||||
return keyMap.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetch JWT public keys from Vault, не мутируя глобальный keyMap.
|
||||
* Возвращает новую Map для атомарного swap'а.
|
||||
*/
|
||||
export async function fetchJwtKeysFromVault(
|
||||
vaultAddr: string,
|
||||
vaultToken: string,
|
||||
mount: string,
|
||||
kidPath: string,
|
||||
kidsPrefix: string,
|
||||
): Promise<void> {
|
||||
const { fetchVaultKV2 } = await import('../config/vault');
|
||||
|
||||
): Promise<Map<string, KeyType>> {
|
||||
const kidData = await fetchVaultKV2(vaultAddr, vaultToken, mount, kidPath);
|
||||
if (!kidData) {
|
||||
logger.warn('Failed to read JWT kid config from Vault');
|
||||
return;
|
||||
throw new Error('Failed to read JWT kid config from Vault');
|
||||
}
|
||||
|
||||
const kids: string[] = [];
|
||||
@@ -41,10 +62,11 @@ export async function loadJwtKeysFromVault(
|
||||
if (kidData.previous && kidData.previous !== kidData.active) kids.push(kidData.previous);
|
||||
|
||||
if (kids.length === 0) {
|
||||
logger.warn('No active/previous kids found in Vault');
|
||||
return;
|
||||
throw new Error('No active/previous kids found in Vault');
|
||||
}
|
||||
|
||||
const next = new Map<string, KeyType>();
|
||||
|
||||
for (const kid of kids) {
|
||||
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
|
||||
if (!kidSecret?.public_key) {
|
||||
@@ -52,16 +74,15 @@ export async function loadJwtKeysFromVault(
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
|
||||
keyMap.set(kid, key);
|
||||
logger.info(`Loaded JWT public key for kid=${kid}`);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to import public key for kid=${kid}: ${err.message}`);
|
||||
}
|
||||
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
|
||||
next.set(kid, key);
|
||||
}
|
||||
|
||||
logger.info(`JWT key store loaded: ${keyMap.size} key(s)`);
|
||||
if (next.size === 0) {
|
||||
throw new Error('No public keys could be loaded from Vault');
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string): Promise<AuthContext> {
|
||||
@@ -71,17 +92,17 @@ export async function verifyAccessToken(token: string): Promise<AuthContext> {
|
||||
const header = jose.decodeProtectedHeader(token);
|
||||
const kid = header.kid;
|
||||
|
||||
if (!kid) {
|
||||
throw Object.assign(new Error('Missing kid in token header'), { status: 401 });
|
||||
if (!kid || typeof kid !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(kid)) {
|
||||
throw Object.assign(new Error('Missing or invalid kid in token header'), { status: 401 });
|
||||
}
|
||||
|
||||
const key = keyMap.get(kid);
|
||||
if (!key) {
|
||||
logger.warn(`Unknown kid=${kid}`);
|
||||
throw Object.assign(new Error('Unknown token kid'), { status: 401 });
|
||||
}
|
||||
|
||||
if (header.alg !== env.jwt.algorithm) {
|
||||
// Двойная защита от algorithm confusion: проверяем точное совпадение
|
||||
if (header.alg !== env.jwt.algorithm || !ALLOWED_ALGORITHMS.has(String(header.alg))) {
|
||||
throw Object.assign(new Error('Invalid token algorithm'), { status: 401 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { env, getVaultToken } from '../config/env';
|
||||
import { vaultAppRoleLogin } from '../config/vault';
|
||||
import { loadJwtKeysFromVault } from './jwt.service';
|
||||
import { loadCsrfSecret } from './csrf.service';
|
||||
import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service';
|
||||
import { fetchCsrfConfig, swapCsrfConfig } from './csrf.service';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
@@ -10,9 +10,8 @@ let timer: NodeJS.Timeout | null = null;
|
||||
let currentVaultToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Refresh JWT public keys (active + previous) and CSRF secret from Vault.
|
||||
* Errors are logged but do NOT throw — старые значения остаются в памяти,
|
||||
* сервис продолжает работать до следующего успешного refresh.
|
||||
* Atomic refresh: pre-fetch JWT keys + CSRF config, swap globals only if BOTH succeed.
|
||||
* При любой ошибке оставляем старые значения в памяти, сервис продолжает работать.
|
||||
*/
|
||||
export async function refreshAllKeys(): Promise<void> {
|
||||
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath } = env.vault;
|
||||
@@ -22,7 +21,7 @@ export async function refreshAllKeys(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use token from initEnv first call; re-login only if we don't have one yet.
|
||||
// Vault token: используем закэшированный из initEnv, либо логинимся заново
|
||||
let token = currentVaultToken || getVaultToken();
|
||||
if (!token) {
|
||||
const fresh = await vaultAppRoleLogin(addr, roleId, secretId);
|
||||
@@ -34,19 +33,32 @@ export async function refreshAllKeys(): Promise<void> {
|
||||
currentVaultToken = fresh;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to refresh JWT keys: ${err.message}`);
|
||||
// ── Pre-fetch обоих секретов параллельно (НЕ мутируя глобал) ───────────
|
||||
const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
|
||||
const csrfPromise = csrfPath ? fetchCsrfConfig(addr, token, mount, csrfPath) : Promise.resolve(null);
|
||||
|
||||
const [jwtResult, csrfResult] = await Promise.allSettled([jwtPromise, csrfPromise]);
|
||||
|
||||
// ── Атомарность: если хоть один обязательный fetch упал — НИЧЕГО не меняем ──
|
||||
if (jwtResult.status === 'rejected') {
|
||||
logger.error(`Key refresh ABORTED — JWT keys fetch failed: ${jwtResult.reason?.message || jwtResult.reason}`);
|
||||
return;
|
||||
}
|
||||
if (csrfPath && csrfResult.status === 'rejected') {
|
||||
logger.error(`Key refresh ABORTED — CSRF fetch failed: ${csrfResult.reason?.message || csrfResult.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (csrfPath) {
|
||||
try {
|
||||
await loadCsrfSecret(addr, token, mount, csrfPath);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to refresh CSRF secret: ${err.message}`);
|
||||
}
|
||||
// ── Atomic swap (синхронные операции, нельзя прервать) ──────────────────
|
||||
swapKeyMap(jwtResult.value);
|
||||
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
|
||||
swapCsrfConfig(csrfResult.value);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Keys refreshed atomically: JWT keys=${getKeyMapSize()}` +
|
||||
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void {
|
||||
@@ -56,8 +68,7 @@ export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void
|
||||
void refreshAllKeys().catch((err) =>
|
||||
logger.error(`Key rotation tick failed: ${err?.message || err}`)
|
||||
);
|
||||
// On token expiry Vault will return 403 — we need to re-login.
|
||||
// Reset cached token so refreshAllKeys re-logs in on next call.
|
||||
// На каждый тик — invalidate Vault token (он мог истечь), будет re-login
|
||||
currentVaultToken = null;
|
||||
}, intervalMs);
|
||||
logger.info(`Key rotation scheduled (every ${intervalMs}ms)`);
|
||||
|
||||
490
apps/api/src/services/wallet-ops.service.ts
Normal file
490
apps/api/src/services/wallet-ops.service.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Wallet operations across chains: balance, transactions, build unsigned send tx.
|
||||
* Non-custodial: server NEVER signs — клиент подписывает приватом.
|
||||
*/
|
||||
import { ethers } from 'ethers';
|
||||
import { env } from '../config/env';
|
||||
|
||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
// ── External APIs ──
|
||||
const BLOCKSTREAM = 'https://blockstream.info/api';
|
||||
const TRONGRID = 'https://api.trongrid.io';
|
||||
const BSC_RPC = 'https://bsc-dataseed.binance.org';
|
||||
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
|
||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||
|
||||
const USDT_TRC20 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
||||
const USDT_BEP20 = '0x55d398326f99059fF775485246999027B3197955';
|
||||
const USDT_ERC20 = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function balanceOf(address owner) view returns (uint256)',
|
||||
'function transfer(address to, uint256 amount) returns (bool)',
|
||||
'function decimals() view returns (uint8)',
|
||||
];
|
||||
|
||||
// ─────────────────────── BALANCE ───────────────────────
|
||||
|
||||
export interface BalanceResult {
|
||||
chain: ChainCode;
|
||||
address: string;
|
||||
native: string; // в smallest units (satoshi/wei/lamports/sun)
|
||||
tokens?: Record<string, string>; // например { USDT: "12345678" }
|
||||
}
|
||||
|
||||
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
return { chain, address, native: await btcBalance(address) };
|
||||
case 'TRX': {
|
||||
const { trx, usdt } = await trxBalance(address);
|
||||
return { chain, address, native: trx, tokens: { USDT: usdt } };
|
||||
}
|
||||
case 'BSC': {
|
||||
const { native, tokens } = await evmBalance(BSC_RPC, address, [{ symbol: 'USDT', addr: USDT_BEP20 }]);
|
||||
return { chain, address, native, tokens };
|
||||
}
|
||||
case 'ETH': {
|
||||
const { native, tokens } = await evmBalance(ETH_RPC, address, [{ symbol: 'USDT', addr: USDT_ERC20 }]);
|
||||
return { chain, address, native, tokens };
|
||||
}
|
||||
case 'SOL':
|
||||
return { chain, address, native: await solBalance(address) };
|
||||
}
|
||||
}
|
||||
|
||||
async function btcBalance(address: string): Promise<string> {
|
||||
const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`);
|
||||
const stats = res.chain_stats;
|
||||
const sat = BigInt(stats.funded_txo_sum) - BigInt(stats.spent_txo_sum);
|
||||
return sat.toString();
|
||||
}
|
||||
|
||||
async function trxBalance(address: string): Promise<{ trx: string; usdt: string }> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
const accRes = await fetchJson(`${TRONGRID}/v1/accounts/${address}`, { headers });
|
||||
const trx = accRes.data?.[0]?.balance ? String(accRes.data[0].balance) : '0';
|
||||
|
||||
// USDT TRC20 balance
|
||||
const usdtRes = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
owner_address: address,
|
||||
contract_address: USDT_TRC20,
|
||||
function_selector: 'balanceOf(address)',
|
||||
parameter: tronAddressToHex(address).padStart(64, '0'),
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
const usdtHex = usdtRes.constant_result?.[0];
|
||||
const usdt = usdtHex && !/^0+$/.test(usdtHex) ? BigInt('0x' + usdtHex).toString() : '0';
|
||||
|
||||
return { trx, usdt };
|
||||
}
|
||||
|
||||
async function evmBalance(
|
||||
rpc: string,
|
||||
address: string,
|
||||
tokens: { symbol: string; addr: string }[],
|
||||
): Promise<{ native: string; tokens: Record<string, string> }> {
|
||||
const provider = new ethers.providers.StaticJsonRpcProvider(rpc);
|
||||
const native = await withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout');
|
||||
|
||||
const tokenBalances: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
tokens.map(async ({ symbol, addr }) => {
|
||||
try {
|
||||
const c = new ethers.Contract(addr, ERC20_ABI, provider);
|
||||
const bal: ethers.BigNumber = await withTimeout(c.balanceOf(address), TIMEOUT_MS, `${symbol} balance timeout`);
|
||||
tokenBalances[symbol] = bal.toString();
|
||||
} catch {
|
||||
tokenBalances[symbol] = '0';
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { native: native.toString(), tokens: tokenBalances };
|
||||
}
|
||||
|
||||
async function solBalance(address: string): Promise<string> {
|
||||
const res = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getBalance',
|
||||
params: [address],
|
||||
}),
|
||||
});
|
||||
return String(res.result?.value ?? 0);
|
||||
}
|
||||
|
||||
// ─────────────────────── TRANSACTIONS ───────────────────────
|
||||
|
||||
export interface TxItem {
|
||||
txid: string;
|
||||
timestamp: number | null; // unix seconds
|
||||
direction: 'in' | 'out' | 'self';
|
||||
amount?: string;
|
||||
token?: string;
|
||||
to?: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export async function getTransactions(chain: ChainCode, address: string, limit: number): Promise<TxItem[]> {
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
return btcTransactions(address, limit);
|
||||
case 'TRX':
|
||||
return trxTransactions(address, limit);
|
||||
case 'BSC':
|
||||
return scanTransactions('https://api.bscscan.com/api', env.bscscanApiKey, address, limit);
|
||||
case 'ETH':
|
||||
return scanTransactions('https://api.etherscan.io/api', env.etherscanApiKey, address, limit);
|
||||
case 'SOL':
|
||||
return solTransactions(address, limit);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanTransactions(
|
||||
apiBase: string,
|
||||
apiKey: string | null,
|
||||
address: string,
|
||||
limit: number,
|
||||
): Promise<TxItem[]> {
|
||||
if (!apiKey) return [];
|
||||
|
||||
const url = new URL(apiBase);
|
||||
url.searchParams.set('module', 'account');
|
||||
url.searchParams.set('action', 'txlist');
|
||||
url.searchParams.set('address', address);
|
||||
url.searchParams.set('startblock', '0');
|
||||
url.searchParams.set('endblock', '99999999');
|
||||
url.searchParams.set('page', '1');
|
||||
url.searchParams.set('offset', String(Math.min(limit, 100)));
|
||||
url.searchParams.set('sort', 'desc');
|
||||
url.searchParams.set('apikey', apiKey);
|
||||
|
||||
const res = await fetchJson(url.toString());
|
||||
if (res.status !== '1' || !Array.isArray(res.result)) return [];
|
||||
|
||||
return (res.result as any[]).slice(0, limit).map((tx) => {
|
||||
const isOut = String(tx.from).toLowerCase() === address.toLowerCase();
|
||||
return {
|
||||
txid: tx.hash,
|
||||
timestamp: tx.timeStamp ? parseInt(tx.timeStamp, 10) : null,
|
||||
direction: (isOut ? 'out' : 'in') as TxItem['direction'],
|
||||
amount: tx.value || undefined,
|
||||
from: tx.from,
|
||||
to: tx.to,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function btcTransactions(address: string, limit: number): Promise<TxItem[]> {
|
||||
const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`);
|
||||
return (txs as any[]).slice(0, limit).map((tx) => {
|
||||
const inSelf = tx.vin.some((v: any) => v.prevout?.scriptpubkey_address === address);
|
||||
const outSelf = tx.vout.some((v: any) => v.scriptpubkey_address === address);
|
||||
const direction: TxItem['direction'] = inSelf && outSelf ? 'self' : inSelf ? 'out' : 'in';
|
||||
return {
|
||||
txid: tx.txid,
|
||||
timestamp: tx.status?.block_time ?? null,
|
||||
direction,
|
||||
amount: String(
|
||||
tx.vout
|
||||
.filter((v: any) => (direction === 'in' ? v.scriptpubkey_address === address : v.scriptpubkey_address !== address))
|
||||
.reduce((s: bigint, v: any) => s + BigInt(v.value), 0n),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function trxTransactions(address: string, limit: number): Promise<TxItem[]> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
const res = await fetchJson(
|
||||
`${TRONGRID}/v1/accounts/${address}/transactions?limit=${limit}`,
|
||||
{ headers },
|
||||
);
|
||||
return ((res.data as any[]) || []).slice(0, limit).map((tx) => {
|
||||
const contract = tx.raw_data?.contract?.[0];
|
||||
const value = contract?.parameter?.value;
|
||||
const fromAddr = value?.owner_address ? hexToTron(value.owner_address) : '';
|
||||
const toAddr = value?.to_address ? hexToTron(value.to_address) : '';
|
||||
const isOut = fromAddr === address;
|
||||
return {
|
||||
txid: tx.txID,
|
||||
timestamp: tx.block_timestamp ? Math.floor(tx.block_timestamp / 1000) : null,
|
||||
direction: (isOut ? 'out' : 'in') as TxItem['direction'],
|
||||
amount: value?.amount ? String(value.amount) : undefined,
|
||||
from: fromAddr || undefined,
|
||||
to: toAddr || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function solTransactions(address: string, limit: number): Promise<TxItem[]> {
|
||||
const res = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getSignaturesForAddress',
|
||||
params: [address, { limit }],
|
||||
}),
|
||||
});
|
||||
return ((res.result as any[]) || []).map((sig) => ({
|
||||
txid: sig.signature,
|
||||
timestamp: sig.blockTime ?? null,
|
||||
direction: 'self' as const, // без deep parsing — направление неизвестно
|
||||
}));
|
||||
}
|
||||
|
||||
// ─────────────────────── BUILD SEND (UNSIGNED TX) ───────────────────────
|
||||
|
||||
export interface BuildSendParams {
|
||||
chain: ChainCode;
|
||||
from: string;
|
||||
to: string;
|
||||
amount: string;
|
||||
token?: string; // 'USDT' и т.д.; для native перевода — undefined
|
||||
}
|
||||
|
||||
export type UnsignedTx =
|
||||
| { kind: 'btc'; from: string; to: string; amountSat: string; utxos: any[]; feeRateSatPerVb: number }
|
||||
| { kind: 'tron'; transaction: any }
|
||||
| { kind: 'evm'; to: string; data: string; value: string; chainId: number; gasLimit?: string }
|
||||
| { kind: 'solana'; instructions: any; recentBlockhash: string };
|
||||
|
||||
export async function buildSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
switch (p.chain) {
|
||||
case 'BTC':
|
||||
return buildBtcSend(p);
|
||||
case 'TRX':
|
||||
return buildTrxSend(p);
|
||||
case 'BSC':
|
||||
return buildEvmSend(p, BSC_RPC, 56, USDT_BEP20);
|
||||
case 'ETH':
|
||||
return buildEvmSend(p, ETH_RPC, 1, USDT_ERC20);
|
||||
case 'SOL':
|
||||
return buildSolSend(p);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildBtcSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
if (p.token) throw new Error('BTC tokens not supported');
|
||||
const utxos = await fetchJson(`${BLOCKSTREAM}/address/${p.from}/utxo`);
|
||||
const fees = await fetchJson(`${BLOCKSTREAM}/fee-estimates`);
|
||||
const confirmed = ((utxos as any[]) || []).filter((u) => u.status?.confirmed);
|
||||
|
||||
return {
|
||||
kind: 'btc',
|
||||
from: p.from,
|
||||
to: p.to,
|
||||
amountSat: p.amount,
|
||||
utxos: confirmed.map((u) => ({ txid: u.txid, vout: u.vout, value: u.value })),
|
||||
feeRateSatPerVb: Math.ceil((fees as any)['3'] ?? (fees as any)['6'] ?? 5),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildTrxSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
if (!p.token) {
|
||||
// Native TRX
|
||||
const res = await fetchJson(`${TRONGRID}/wallet/createtransaction`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ owner_address: p.from, to_address: p.to, amount: Number(p.amount), visible: true }),
|
||||
});
|
||||
return { kind: 'tron', transaction: res };
|
||||
}
|
||||
|
||||
if (p.token.toUpperCase() === 'USDT') {
|
||||
// TRC20 USDT
|
||||
const param = tronAddressToHex(p.to).padStart(64, '0') + BigInt(p.amount).toString(16).padStart(64, '0');
|
||||
const res = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
owner_address: p.from,
|
||||
contract_address: USDT_TRC20,
|
||||
function_selector: 'transfer(address,uint256)',
|
||||
parameter: param,
|
||||
fee_limit: 100_000_000,
|
||||
call_value: 0,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
return { kind: 'tron', transaction: res };
|
||||
}
|
||||
|
||||
throw new Error(`Token ${p.token} not supported on TRX`);
|
||||
}
|
||||
|
||||
async function buildEvmSend(p: BuildSendParams, rpc: string, chainId: number, usdtAddr: string): Promise<UnsignedTx> {
|
||||
if (!ethers.utils.isAddress(p.to)) throw new Error('Invalid recipient address');
|
||||
|
||||
if (!p.token) {
|
||||
return { kind: 'evm', to: p.to, data: '0x', value: ethers.BigNumber.from(p.amount).toHexString(), chainId };
|
||||
}
|
||||
|
||||
if (p.token.toUpperCase() === 'USDT') {
|
||||
const iface = new ethers.utils.Interface(ERC20_ABI);
|
||||
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
|
||||
return { kind: 'evm', to: usdtAddr, data, value: '0x0', chainId };
|
||||
}
|
||||
|
||||
throw new Error(`Token ${p.token} not supported on ${chainId === 56 ? 'BSC' : 'ETH'}`);
|
||||
}
|
||||
|
||||
async function buildSolSend(p: BuildSendParams): Promise<UnsignedTx> {
|
||||
const {
|
||||
Connection,
|
||||
PublicKey,
|
||||
SystemProgram,
|
||||
Transaction,
|
||||
} = await import('@solana/web3.js');
|
||||
|
||||
const conn = new Connection(SOL_RPC, 'confirmed');
|
||||
|
||||
let fromPk: InstanceType<typeof PublicKey>;
|
||||
let toPk: InstanceType<typeof PublicKey>;
|
||||
try {
|
||||
fromPk = new PublicKey(p.from);
|
||||
toPk = new PublicKey(p.to);
|
||||
} catch {
|
||||
throw new Error('Invalid Solana address');
|
||||
}
|
||||
|
||||
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
|
||||
const tx = new Transaction({
|
||||
feePayer: fromPk,
|
||||
blockhash,
|
||||
lastValidBlockHeight,
|
||||
});
|
||||
|
||||
if (!p.token) {
|
||||
// Native SOL transfer
|
||||
tx.add(
|
||||
SystemProgram.transfer({
|
||||
fromPubkey: fromPk,
|
||||
toPubkey: toPk,
|
||||
lamports: BigInt(p.amount),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// SPL token transfer (manual instruction — не тянем @solana/spl-token)
|
||||
const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
|
||||
const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');
|
||||
|
||||
const mint = solMintFor(p.token);
|
||||
if (!mint) throw new Error(`Unsupported SOL token: ${p.token}`);
|
||||
|
||||
const fromAta = await deriveAta(new PublicKey(mint), fromPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
|
||||
const toAta = await deriveAta(new PublicKey(mint), toPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
|
||||
|
||||
// Transfer instruction (instruction tag = 3 для SPL Token Transfer)
|
||||
const data = Buffer.alloc(9);
|
||||
data.writeUInt8(3, 0);
|
||||
data.writeBigUInt64LE(BigInt(p.amount), 1);
|
||||
|
||||
tx.add({
|
||||
programId: TOKEN_PROGRAM_ID,
|
||||
keys: [
|
||||
{ pubkey: fromAta, isSigner: false, isWritable: true },
|
||||
{ pubkey: toAta, isSigner: false, isWritable: true },
|
||||
{ pubkey: fromPk, isSigner: true, isWritable: false },
|
||||
],
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// Сериализуем сообщение (без подписей) для клиента
|
||||
const serialized = tx.serialize({ requireAllSignatures: false, verifySignatures: false });
|
||||
|
||||
return {
|
||||
kind: 'solana',
|
||||
instructions: serialized.toString('base64'),
|
||||
recentBlockhash: blockhash,
|
||||
};
|
||||
}
|
||||
|
||||
function solMintFor(symbol: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
|
||||
USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
|
||||
};
|
||||
return map[symbol] ?? null;
|
||||
}
|
||||
|
||||
async function deriveAta(
|
||||
mint: any,
|
||||
owner: any,
|
||||
tokenProgramId: any,
|
||||
associatedTokenProgramId: any,
|
||||
): Promise<any> {
|
||||
const { PublicKey } = await import('@solana/web3.js');
|
||||
const [pda] = await PublicKey.findProgramAddress(
|
||||
[owner.toBuffer(), tokenProgramId.toBuffer(), mint.toBuffer()],
|
||||
associatedTokenProgramId,
|
||||
);
|
||||
return pda;
|
||||
}
|
||||
|
||||
// ─────────────────────── HELPERS ───────────────────────
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
function tronAddressToHex(address: string): string {
|
||||
let num = 0n;
|
||||
for (const ch of address) {
|
||||
const i = BASE58_ALPHABET.indexOf(ch);
|
||||
if (i === -1) throw new Error('Invalid base58 character in TRON address');
|
||||
num = num * 58n + BigInt(i);
|
||||
}
|
||||
const hex = num.toString(16).padStart(50, '0');
|
||||
return hex.slice(2, 42); // 20 bytes без префикса 0x41
|
||||
}
|
||||
|
||||
function hexToTron(hex: string): string {
|
||||
// Формат TronGrid: 41xxxx (20 bytes after prefix). Это base58check.
|
||||
// Для простоты возвращаем как есть hex (можно улучшить на full base58check если нужно).
|
||||
return hex;
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Upstream ${res.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, msg: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(msg)), ms);
|
||||
promise.then(
|
||||
(v) => { clearTimeout(t); resolve(v); },
|
||||
(e) => { clearTimeout(t); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user