Files
cryptowallet/apps/api/src/services/wallet-ops.service.ts
ZOMBIIIIIII 3a890b79ee initkkk
2026-05-13 00:37:00 +03:00

731 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Wallet read-only operations across chains: balance + tx history.
* Server-side signing now lives in `wallet-signer.service.ts` (custodial).
*/
import { ethers } from 'ethers';
import { createHash } from 'crypto';
import { env } from '../config/env';
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
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 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 FormattedAmount {
raw: string; // smallest units (string-encoded BigInt — без потери точности)
formatted: string; // human-readable, e.g. "0.003"
decimals: number; // decimals chain'а/токена
}
export interface BalanceResult {
chain: ChainCode;
address: string;
native: FormattedAmount;
tokens?: Record<string, FormattedAmount>;
}
// Native decimals per chain
const NATIVE_DECIMALS: Record<ChainCode, number> = {
ETH: 18, // wei
BSC: 18, // wei (BNB)
BTC: 8, // satoshi
TRX: 6, // sun
SOL: 9, // lamports
};
/**
* Convert raw bigint string in smallest units → human-readable decimal string.
* Без потери точности (string-based, не Number/Float).
*
* formatUnits("3000000000000000", 18) → "0.003"
* formatUnits("1500000", 6) → "1.5"
* formatUnits("123456", 0) → "123456"
*/
export function formatUnits(raw: string, decimals: number): string {
if (!/^-?\d+$/.test(raw)) return '0';
if (decimals === 0) return raw;
const negative = raw.startsWith('-');
const abs = negative ? raw.slice(1) : raw;
const padded = abs.padStart(decimals + 1, '0');
const whole = padded.slice(0, padded.length - decimals);
const frac = padded.slice(-decimals).replace(/0+$/, '');
const result = frac ? `${whole}.${frac}` : whole;
return negative ? `-${result}` : result;
}
function fmt(raw: string, decimals: number): FormattedAmount {
return { raw, formatted: formatUnits(raw, decimals), decimals };
}
function fmtTokens(
raw: Record<string, string>,
decimalsLookup: Record<string, number>,
): Record<string, FormattedAmount> {
const out: Record<string, FormattedAmount> = {};
for (const [sym, val] of Object.entries(raw)) {
out[sym] = fmt(val, decimalsLookup[sym] ?? 0);
}
return out;
}
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
const nativeDecimals = NATIVE_DECIMALS[chain];
switch (chain) {
case 'BTC':
return {
chain, address,
native: fmt(await btcBalance(address), nativeDecimals),
};
case 'TRX': {
const { trx, tokens } = await trxBalance(address);
const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals]));
return {
chain, address,
native: fmt(trx, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap),
};
}
case 'BSC':
case 'ETH': {
const tokenList = getEvmTokens(chain);
const rpc = chain === 'BSC' ? BSC_RPC : ETH_RPC;
const { native, tokens } = await evmBalance(
rpc,
address,
tokenList.map((t) => ({ symbol: t.symbol, addr: t.contractAddress })),
);
const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals]));
return {
chain, address,
native: fmt(native, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap),
};
}
case 'SOL': {
const { native, tokens } = await solBalance(address);
const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals]));
return {
chain, address,
native: fmt(native, nativeDecimals),
tokens: fmtTokens(tokens, decimalsMap),
};
}
}
}
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; tokens: Record<string, 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';
// Parallel TRC20 balanceOf для всех зарегистрированных токенов
const tokens: Record<string, string> = {};
const addrHex = tronAddressToHex(address).padStart(64, '0');
await Promise.all(
getTrxTokens().map(async ({ symbol, contractAddress }) => {
try {
const res = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: address,
contract_address: contractAddress,
function_selector: 'balanceOf(address)',
parameter: addrHex,
visible: true,
}),
});
const hex = res.constant_result?.[0];
tokens[symbol] = hex && !/^0+$/.test(hex) ? BigInt('0x' + hex).toString() : '0';
} catch {
tokens[symbol] = '0';
}
}),
);
return { trx, tokens };
}
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);
// 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> = {};
await Promise.allSettled(
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';
}
}),
);
const native = await nativeP;
return { native, tokens: tokenBalances };
}
async function solBalance(address: string): Promise<{ native: string; tokens: Record<string, string> }> {
// 1) Native SOL balance
const nativeRes = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getBalance',
params: [address],
}),
});
const native = String(nativeRes.result?.value ?? 0);
// 2) Все SPL token accounts юзера одним запросом
const tokens: Record<string, string> = {};
const knownMints = new Map(getSolTokens().map((t) => [t.mint, t.symbol]));
// Сразу инициализируем все известные символы нулём (чтобы output был consistent)
for (const [, sym] of knownMints) tokens[sym] = '0';
try {
const splRes = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getTokenAccountsByOwner',
params: [
address,
{ programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' }, // SPL Token program
{ encoding: 'jsonParsed' },
],
}),
});
const accounts = splRes.result?.value || [];
for (const acc of accounts) {
const info = acc?.account?.data?.parsed?.info;
const mint = info?.mint;
const amount = info?.tokenAmount?.amount;
if (mint && amount && knownMints.has(mint)) {
const symbol = knownMints.get(mint)!;
// Суммируем если несколько token accounts для одного mint
const prev = BigInt(tokens[symbol] || '0');
tokens[symbol] = (prev + BigInt(amount)).toString();
}
}
} catch {
// SOL RPC недоступен — оставляем нули
}
return { native, tokens };
}
// ─────────────────────── 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 vin = Array.isArray(tx.vin) ? tx.vin : [];
const vout = Array.isArray(tx.vout) ? tx.vout : [];
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 {
txid: tx.txid,
timestamp: tx.status?.block_time ?? null,
direction,
amount: String(amountSat),
};
});
}
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 sigsRes = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getSignaturesForAddress',
params: [address, { limit }],
}),
});
// 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,
timestamp: sig.blockTime ?? null,
direction,
amount,
});
} catch {
// Если getTransaction fails — fallback на minimal entry
results.push({
txid: sig.signature,
timestamp: sig.blockTime ?? null,
direction: 'self',
});
}
}
return results;
}
// ─────────────────────── HELPERS ───────────────────────
// (buildSend + chain-specific builders deleted — server signs custodially via wallet-signer.service.ts)
/* deleted-marker-begin
export interface BuildSendParams {
chain: ChainCode;
from: string;
to: string;
amount: string;
token?: string;
}
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;
}
deleted-marker-end */
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
}
/**
* 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 {
if (!hex) return '';
// Принимаем 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;
}
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> {
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); },
);
});
}