initkkk
This commit is contained in:
@@ -10,7 +10,6 @@ import { authMiddleware } from './middleware/auth';
|
|||||||
import { csrfMiddleware } from './middleware/csrf';
|
import { csrfMiddleware } from './middleware/csrf';
|
||||||
import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit';
|
import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit';
|
||||||
import { errorHandler } from './middleware/error-handler';
|
import { errorHandler } from './middleware/error-handler';
|
||||||
import { WalletController } from './controllers/wallet.controller';
|
|
||||||
import walletRoutes from './routes/wallet.routes';
|
import walletRoutes from './routes/wallet.routes';
|
||||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||||
import tronProxyRoutes from './routes/tron-proxy.routes';
|
import tronProxyRoutes from './routes/tron-proxy.routes';
|
||||||
@@ -85,12 +84,11 @@ app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
|||||||
const protect = [authMiddleware, csrfMiddleware];
|
const protect = [authMiddleware, csrfMiddleware];
|
||||||
|
|
||||||
// Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF.
|
// Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF.
|
||||||
|
app.use('/api/wallets/create', ...protect, sensitiveLimiter);
|
||||||
app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter);
|
app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter);
|
||||||
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
|
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
|
||||||
|
|
||||||
// Mutating (proxy + read endpoints) — повышенный лимит
|
// Mutating (proxy + read endpoints) — повышенный лимит
|
||||||
app.post('/api/wallets/create', sensitiveLimiter, WalletController.createWallet);
|
|
||||||
app.get('/api/wallets', mutateLimiter, WalletController.getWallets);
|
|
||||||
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
|
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
|
||||||
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
|
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
|
||||||
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
|
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
|
||||||
|
|||||||
@@ -30,9 +30,8 @@ export const WalletController = {
|
|||||||
* GET /api/wallets — все адреса юзера.
|
* GET /api/wallets — все адреса юзера.
|
||||||
*/
|
*/
|
||||||
async getWallets(req: Request, res: Response) {
|
async getWallets(req: Request, res: Response) {
|
||||||
const userId = '01KPKAFN6J1NJBY15DX8JE2QYB';
|
|
||||||
try {
|
try {
|
||||||
const wallets = await WalletModel.findByUserId(userId);
|
const wallets = await WalletModel.findByUserId(req.auth!.userId);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: wallets.map((w) => ({
|
data: wallets.map((w) => ({
|
||||||
@@ -42,7 +41,7 @@ export const WalletController = {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
logger.error(`getWallets failed for user ${userId}: ${err.stack || err.message}`);
|
logger.error(`getWallets failed for user ${req.auth!.userId}: ${err.stack || err.message}`);
|
||||||
res.status(500).json({ success: false, error: 'Internal error' });
|
res.status(500).json({ success: false, error: 'Internal error' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -54,7 +53,7 @@ export const WalletController = {
|
|||||||
* Возвращает: ТОЛЬКО адреса. Mnemonic клиенту не отдаём.
|
* Возвращает: ТОЛЬКО адреса. Mnemonic клиенту не отдаём.
|
||||||
*/
|
*/
|
||||||
async createWallet(req: Request, res: Response) {
|
async createWallet(req: Request, res: Response) {
|
||||||
const userId = '01KPKAFN6J1NJBY15DX8JE2QYB';
|
const userId = req.auth!.userId;
|
||||||
|
|
||||||
if (!isCryptoReady()) {
|
if (!isCryptoReady()) {
|
||||||
res.status(503).json({ success: false, error: 'Crypto service not ready' });
|
res.status(503).json({ success: false, error: 'Crypto service not ready' });
|
||||||
@@ -65,20 +64,36 @@ export const WalletController = {
|
|||||||
try {
|
try {
|
||||||
await UserModel.ensureExists(userId);
|
await UserModel.ensureExists(userId);
|
||||||
|
|
||||||
if (await UserModel.hasMnemonic(userId)) {
|
// H14 — gate wallet creation behind KYC verification (опционально, controlled by env).
|
||||||
|
// Если REQUIRE_KYC_FOR_WALLET=true в env, требуется kyc_verified=true.
|
||||||
|
if (process.env.REQUIRE_KYC_FOR_WALLET === 'true') {
|
||||||
|
const isVerified = await UserModel.isKycVerified(userId);
|
||||||
|
if (!isVerified) {
|
||||||
|
res.status(403).json({ success: false, error: 'KYC verification required before wallet creation' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// H27 — claim placeholder ПЕРЕД derive. Loser race не тратит CPU + memory secrets.
|
||||||
|
// Атомарно: UPDATE WHERE encrypted_mnemonic IS NULL SET = 'PENDING_DERIVATION'
|
||||||
|
const claimResult = await UserModel.claimWalletSlot(userId);
|
||||||
|
if (claimResult === 'already_has') {
|
||||||
res.status(409).json({ success: false, error: 'Wallet already exists' });
|
res.status(409).json({ success: false, error: 'Wallet already exists' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (claimResult === 'no_user') {
|
||||||
|
res.status(404).json({ success: false, error: 'User not found or deleted' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// claimResult === 'claimed' — proceed
|
||||||
|
|
||||||
mnemonic = generateMnemonic();
|
mnemonic = generateMnemonic();
|
||||||
const derived = await deriveAllAddresses(mnemonic);
|
const derived = await deriveAllAddresses(mnemonic);
|
||||||
const blob = encryptMnemonic(mnemonic);
|
const blob = encryptMnemonic(mnemonic);
|
||||||
|
|
||||||
const created = await db.transaction(async (trx) => {
|
const created = await db.transaction(async (trx) => {
|
||||||
const claimed = await UserModel.setEncryptedMnemonicIfAbsent(userId, blob, trx);
|
// H32 — finalize placeholder (must succeed since claim won earlier)
|
||||||
if (!claimed) {
|
await UserModel.finalizeWalletSlot(userId, blob, trx);
|
||||||
throw new ConflictError();
|
|
||||||
}
|
|
||||||
await WalletModel.createMany(
|
await WalletModel.createMany(
|
||||||
derived.map((w) => ({
|
derived.map((w) => ({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -88,8 +103,7 @@ export const WalletController = {
|
|||||||
})),
|
})),
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
// Дублируем ETH-адрес в users.erc20 — это поле прода-схемы
|
// Дублируем ETH-адрес в users.erc20
|
||||||
// (custodial wallet's ETH address, доступный через простой SELECT без JOIN'а на wallets).
|
|
||||||
const ethWallet = derived.find((w) => w.chain === 'ETH');
|
const ethWallet = derived.find((w) => w.chain === 'ETH');
|
||||||
if (ethWallet) {
|
if (ethWallet) {
|
||||||
await UserModel.setErc20Address(userId, ethWallet.address, trx);
|
await UserModel.setErc20Address(userId, ethWallet.address, trx);
|
||||||
|
|||||||
@@ -66,11 +66,17 @@ export const UserModel = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set-once: возвращает true только если этот вызов реально занял slot.
|
* Set-once: возвращает 'claimed' / 'already_has' / 'no_user' (H32).
|
||||||
* Защита от race: два параллельных createWallet не могут оба перезаписать.
|
* Defense-in-depth: distinguishes wrong outcomes так что caller отдаёт правильный код:
|
||||||
* Также filter is_deleted=false — не давать zombie-account resurrection.
|
* - claimed → каждый параллельный call'у вернёт already_has потом (мы выиграли gонку)
|
||||||
|
* - already_has → existing encrypted_mnemonic в DB
|
||||||
|
* - no_user → row not found OR is_deleted=true
|
||||||
*/
|
*/
|
||||||
async setEncryptedMnemonicIfAbsent(id: string, blob: string, trx?: Knex.Transaction): Promise<boolean> {
|
async setEncryptedMnemonicIfAbsent(
|
||||||
|
id: string,
|
||||||
|
blob: string,
|
||||||
|
trx?: Knex.Transaction,
|
||||||
|
): Promise<'claimed' | 'already_has' | 'no_user'> {
|
||||||
const k = trx || db;
|
const k = trx || db;
|
||||||
const affected = await k('users')
|
const affected = await k('users')
|
||||||
.where({ id, is_deleted: false })
|
.where({ id, is_deleted: false })
|
||||||
@@ -79,7 +85,60 @@ export const UserModel = {
|
|||||||
encrypted_mnemonic: blob,
|
encrypted_mnemonic: blob,
|
||||||
updated_at: k.fn.now(),
|
updated_at: k.fn.now(),
|
||||||
});
|
});
|
||||||
return affected === 1;
|
if (affected === 1) return 'claimed';
|
||||||
|
// Affected 0 — либо user gone, либо уже есть mnemonic. Distinguish.
|
||||||
|
const row = await k('users')
|
||||||
|
.where({ id, is_deleted: false })
|
||||||
|
.select('encrypted_mnemonic')
|
||||||
|
.first();
|
||||||
|
if (!row) return 'no_user';
|
||||||
|
return row.encrypted_mnemonic ? 'already_has' : 'no_user';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim placeholder row перед derive — экономит CPU + heap-secret для loser race.
|
||||||
|
* Используется как pre-step в createWallet flow (H27).
|
||||||
|
*/
|
||||||
|
async claimWalletSlot(id: string, trx?: Knex.Transaction): Promise<'claimed' | 'already_has' | 'no_user'> {
|
||||||
|
const k = trx || db;
|
||||||
|
const PLACEHOLDER = 'PENDING_DERIVATION';
|
||||||
|
const affected = await k('users')
|
||||||
|
.where({ id, is_deleted: false })
|
||||||
|
.whereNull('encrypted_mnemonic')
|
||||||
|
.update({
|
||||||
|
encrypted_mnemonic: PLACEHOLDER,
|
||||||
|
updated_at: k.fn.now(),
|
||||||
|
});
|
||||||
|
if (affected === 1) return 'claimed';
|
||||||
|
const row = await k('users')
|
||||||
|
.where({ id, is_deleted: false })
|
||||||
|
.select('encrypted_mnemonic')
|
||||||
|
.first();
|
||||||
|
if (!row) return 'no_user';
|
||||||
|
return row.encrypted_mnemonic && row.encrypted_mnemonic !== PLACEHOLDER ? 'already_has' : 'no_user';
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Finalize after claimWalletSlot — overwrite placeholder с real blob. */
|
||||||
|
async finalizeWalletSlot(id: string, blob: string, trx?: Knex.Transaction): Promise<void> {
|
||||||
|
const k = trx || db;
|
||||||
|
const affected = await k('users')
|
||||||
|
.where({ id, is_deleted: false })
|
||||||
|
.update({
|
||||||
|
encrypted_mnemonic: blob,
|
||||||
|
updated_at: k.fn.now(),
|
||||||
|
});
|
||||||
|
if (affected !== 1) {
|
||||||
|
throw new Error(`finalizeWalletSlot: expected 1 row affected, got ${affected} for user ${id}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Check KYC status (H14) */
|
||||||
|
async isKycVerified(id: string): Promise<boolean> {
|
||||||
|
const row = await db('users')
|
||||||
|
.where({ id, is_deleted: false })
|
||||||
|
.select('kyc_verified', 'kyc_verified_at')
|
||||||
|
.first();
|
||||||
|
return Boolean(row?.kyc_verified && row?.kyc_verified_at);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getEncryptedMnemonic(id: string): Promise<string | null> {
|
async getEncryptedMnemonic(id: string): Promise<string | null> {
|
||||||
@@ -100,16 +159,18 @@ export const UserModel = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Записать ETH-адрес custodial-кошелька в users.erc20.
|
* Записать ETH-адрес custodial-кошелька в users.erc20.
|
||||||
* Вызывается из createWallet() внутри той же transaction что и WalletModel.createMany,
|
* Throws (rolls back tx) if user не существует / is_deleted (H31).
|
||||||
* чтобы rollback был consistent (без orphan записей).
|
|
||||||
*/
|
*/
|
||||||
async setErc20Address(id: string, address: string, trx?: Knex.Transaction): Promise<void> {
|
async setErc20Address(id: string, address: string, trx?: Knex.Transaction): Promise<void> {
|
||||||
const k = trx || db;
|
const k = trx || db;
|
||||||
await k('users')
|
const affected = await k('users')
|
||||||
.where({ id, is_deleted: false })
|
.where({ id, is_deleted: false })
|
||||||
.update({
|
.update({
|
||||||
erc20: address,
|
erc20: address,
|
||||||
updated_at: k.fn.now(),
|
updated_at: k.fn.now(),
|
||||||
});
|
});
|
||||||
|
if (affected !== 1) {
|
||||||
|
throw new Error(`setErc20Address: user ${id} not found or deleted (affected=${affected})`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Server-side signing now lives in `wallet-signer.service.ts` (custodial).
|
* Server-side signing now lives in `wallet-signer.service.ts` (custodial).
|
||||||
*/
|
*/
|
||||||
import { ethers } from 'ethers';
|
import { ethers } from 'ethers';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
|
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
|
||||||
|
|
||||||
@@ -177,10 +178,11 @@ async function evmBalance(
|
|||||||
tokens: { symbol: string; addr: string }[],
|
tokens: { symbol: string; addr: string }[],
|
||||||
): Promise<{ native: string; tokens: Record<string, string> }> {
|
): Promise<{ native: string; tokens: Record<string, string> }> {
|
||||||
const provider = new ethers.providers.StaticJsonRpcProvider(rpc);
|
const provider = new ethers.providers.StaticJsonRpcProvider(rpc);
|
||||||
const native = await withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout');
|
// H52 — Promise.allSettled: одна сбоящая RPC subcall не валит весь endpoint в 502
|
||||||
|
const nativeP = withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout').then(b => b.toString()).catch(() => '0');
|
||||||
|
|
||||||
const tokenBalances: Record<string, string> = {};
|
const tokenBalances: Record<string, string> = {};
|
||||||
await Promise.all(
|
await Promise.allSettled(
|
||||||
tokens.map(async ({ symbol, addr }) => {
|
tokens.map(async ({ symbol, addr }) => {
|
||||||
try {
|
try {
|
||||||
const c = new ethers.Contract(addr, ERC20_ABI, provider);
|
const c = new ethers.Contract(addr, ERC20_ABI, provider);
|
||||||
@@ -192,7 +194,8 @@ async function evmBalance(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { native: native.toString(), tokens: tokenBalances };
|
const native = await nativeP;
|
||||||
|
return { native, tokens: tokenBalances };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> {
|
async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> {
|
||||||
@@ -316,18 +319,42 @@ async function scanTransactions(
|
|||||||
async function btcTransactions(address: string, limit: number): Promise<TxItem[]> {
|
async function btcTransactions(address: string, limit: number): Promise<TxItem[]> {
|
||||||
const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`);
|
const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`);
|
||||||
return (txs as any[]).slice(0, limit).map((tx) => {
|
return (txs as any[]).slice(0, limit).map((tx) => {
|
||||||
const inSelf = tx.vin.some((v: any) => v.prevout?.scriptpubkey_address === address);
|
const vin = Array.isArray(tx.vin) ? tx.vin : [];
|
||||||
const outSelf = tx.vout.some((v: any) => v.scriptpubkey_address === address);
|
const vout = Array.isArray(tx.vout) ? tx.vout : [];
|
||||||
const direction: TxItem['direction'] = inSelf && outSelf ? 'self' : inSelf ? 'out' : 'in';
|
const inSelf = vin.some((v: any) => v.prevout?.scriptpubkey_address === address);
|
||||||
|
const allOutsSelf = vout.length > 0 && vout.every((v: any) => v.scriptpubkey_address === address);
|
||||||
|
const anyOutsExternal = vout.some((v: any) => v.scriptpubkey_address && v.scriptpubkey_address !== address);
|
||||||
|
|
||||||
|
// H49 — корректная direction logic:
|
||||||
|
// self = consolidation/tx where ВСЕ outs идут обратно к нам (rare; обычно нет change)
|
||||||
|
// out = мы spend'им (inSelf=true) И есть external recipient
|
||||||
|
// in = мы получаем (НЕ inSelf, есть out к нам)
|
||||||
|
let direction: TxItem['direction'];
|
||||||
|
if (inSelf && allOutsSelf) {
|
||||||
|
direction = 'self';
|
||||||
|
} else if (inSelf && anyOutsExternal) {
|
||||||
|
direction = 'out';
|
||||||
|
} else {
|
||||||
|
direction = 'in';
|
||||||
|
}
|
||||||
|
|
||||||
|
// amount: для 'out' — sum external outs; для 'in' — sum outs к нам; для 'self' — 0
|
||||||
|
let amountSat = 0n;
|
||||||
|
if (direction === 'in') {
|
||||||
|
amountSat = vout
|
||||||
|
.filter((v: any) => v.scriptpubkey_address === address)
|
||||||
|
.reduce((s: bigint, v: any) => s + BigInt(v.value || 0), 0n);
|
||||||
|
} else if (direction === 'out') {
|
||||||
|
amountSat = vout
|
||||||
|
.filter((v: any) => v.scriptpubkey_address && v.scriptpubkey_address !== address)
|
||||||
|
.reduce((s: bigint, v: any) => s + BigInt(v.value || 0), 0n);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
txid: tx.txid,
|
txid: tx.txid,
|
||||||
timestamp: tx.status?.block_time ?? null,
|
timestamp: tx.status?.block_time ?? null,
|
||||||
direction,
|
direction,
|
||||||
amount: String(
|
amount: String(amountSat),
|
||||||
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),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -358,7 +385,7 @@ async function trxTransactions(address: string, limit: number): Promise<TxItem[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function solTransactions(address: string, limit: number): Promise<TxItem[]> {
|
async function solTransactions(address: string, limit: number): Promise<TxItem[]> {
|
||||||
const res = await fetchJson(SOL_RPC, {
|
const sigsRes = await fetchJson(SOL_RPC, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -368,11 +395,56 @@ async function solTransactions(address: string, limit: number): Promise<TxItem[]
|
|||||||
params: [address, { limit }],
|
params: [address, { limit }],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return ((res.result as any[]) || []).map((sig) => ({
|
// H50 — H18: filter out failed txs + deep-parse selected (limit small concurrency).
|
||||||
|
const allSigs = ((sigsRes.result as any[]) || []).filter((s) => s.err === null);
|
||||||
|
|
||||||
|
// Fetch tx details для balance deltas — batch parallel но небольшим limit'ом
|
||||||
|
const results: TxItem[] = [];
|
||||||
|
for (const sig of allSigs.slice(0, limit)) {
|
||||||
|
try {
|
||||||
|
const txRes = await fetchJson(SOL_RPC, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'getTransaction',
|
||||||
|
params: [sig.signature, { maxSupportedTransactionVersion: 0, encoding: 'json' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const tx = txRes.result;
|
||||||
|
const accountKeys = tx?.transaction?.message?.accountKeys || [];
|
||||||
|
const idx = accountKeys.indexOf(address);
|
||||||
|
const pre = tx?.meta?.preBalances?.[idx];
|
||||||
|
const post = tx?.meta?.postBalances?.[idx];
|
||||||
|
let direction: TxItem['direction'] = 'self';
|
||||||
|
let amount: string | undefined;
|
||||||
|
if (typeof pre === 'number' && typeof post === 'number') {
|
||||||
|
const delta = post - pre;
|
||||||
|
if (delta < 0) {
|
||||||
|
direction = 'out';
|
||||||
|
amount = String(-delta);
|
||||||
|
} else if (delta > 0) {
|
||||||
|
direction = 'in';
|
||||||
|
amount = String(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
txid: sig.signature,
|
txid: sig.signature,
|
||||||
timestamp: sig.blockTime ?? null,
|
timestamp: sig.blockTime ?? null,
|
||||||
direction: 'self' as const, // без deep parsing — направление неизвестно
|
direction,
|
||||||
}));
|
amount,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Если getTransaction fails — fallback на minimal entry
|
||||||
|
results.push({
|
||||||
|
txid: sig.signature,
|
||||||
|
timestamp: sig.blockTime ?? null,
|
||||||
|
direction: 'self',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────── HELPERS ───────────────────────
|
// ─────────────────────── HELPERS ───────────────────────
|
||||||
@@ -584,11 +656,53 @@ function tronAddressToHex(address: string): string {
|
|||||||
return hex.slice(2, 42); // 20 bytes без префикса 0x41
|
return hex.slice(2, 42); // 20 bytes без префикса 0x41
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode TRON address: 20-byte hex (без 0x41 prefix) → base58check 'T...' string.
|
||||||
|
* H51 — фронтенд получал raw hex `41...` instead of readable `T...`. Fix.
|
||||||
|
*/
|
||||||
function hexToTron(hex: string): string {
|
function hexToTron(hex: string): string {
|
||||||
// Формат TronGrid: 41xxxx (20 bytes after prefix). Это base58check.
|
if (!hex) return '';
|
||||||
// Для простоты возвращаем как есть hex (можно улучшить на full base58check если нужно).
|
// Принимаем hex с или без префикса 0x41
|
||||||
|
let bytesHex = hex.toLowerCase();
|
||||||
|
if (bytesHex.startsWith('0x')) bytesHex = bytesHex.slice(2);
|
||||||
|
// Если уже 21 byte (с prefix) — оставляем; если 20 — добавляем 0x41
|
||||||
|
if (bytesHex.length === 40) {
|
||||||
|
bytesHex = '41' + bytesHex;
|
||||||
|
} else if (bytesHex.length !== 42) {
|
||||||
|
// Unknown length — fail-safe return raw input для backward compat
|
||||||
return hex;
|
return hex;
|
||||||
}
|
}
|
||||||
|
if (!/^[0-9a-f]+$/.test(bytesHex)) return hex;
|
||||||
|
|
||||||
|
const payload = Buffer.from(bytesHex, 'hex');
|
||||||
|
// SHA256d checksum (4 bytes)
|
||||||
|
const h1 = createHash('sha256').update(payload).digest();
|
||||||
|
const h2 = createHash('sha256').update(h1).digest();
|
||||||
|
const fullBytes = Buffer.concat([payload, h2.subarray(0, 4)]);
|
||||||
|
|
||||||
|
// base58 encode
|
||||||
|
return base58Encode(fullBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE58_ALPHABET_OPS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||||
|
|
||||||
|
function base58Encode(bytes: Buffer): string {
|
||||||
|
let num = 0n;
|
||||||
|
for (const b of bytes) {
|
||||||
|
num = (num << 8n) + BigInt(b);
|
||||||
|
}
|
||||||
|
let s = '';
|
||||||
|
while (num > 0n) {
|
||||||
|
s = BASE58_ALPHABET_OPS[Number(num % 58n)] + s;
|
||||||
|
num /= 58n;
|
||||||
|
}
|
||||||
|
// Leading zero bytes → leading '1's
|
||||||
|
for (const b of bytes) {
|
||||||
|
if (b === 0) s = '1' + s;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
# Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy / Nginx).
|
# Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy / Nginx).
|
||||||
# Для direct exposure в dev → поменяй на "3001:3001".
|
# Для direct exposure в dev → поменяй на "3001:3001".
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "127.0.0.1:3001:3001"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
Reference in New Issue
Block a user