Skip to content

Commit a4baf2f

Browse files
authored
Merge pull request #15 from ecency/balance
Better balance handling
2 parents cc9ad6c + bcf82e4 commit a4baf2f

2 files changed

Lines changed: 154 additions & 135 deletions

File tree

src/server/handlers/private-api.ts

Lines changed: 113 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
apiRequest,
99
getPromotedEntries,
1010
ChainBalanceResponse,
11+
BalanceProvider,
1112
parseBalanceProvider,
1213
fetchChainzBalance,
1314
fetchBlockstreamBalance,
@@ -1769,158 +1770,160 @@ const CHAIN_BROADCAST_HANDLERS: Record<string, ChainBroadcastHandler> = {
17691770
},
17701771
};
17711772

1772-
export const balance = async (req: express.Request, res: express.Response) => {
1773-
const { chain, address } = req.params;
17741773

1774+
/**
1775+
* Fetches balance for a given chain and address directly (without HTTP overhead)
1776+
* This is the core logic extracted from the balance endpoint for reuse
1777+
*/
1778+
export const fetchChainBalance = async (
1779+
chain: string,
1780+
address: string,
1781+
provider: BalanceProvider = "chainstack"
1782+
): Promise<ChainBalanceResponse> => {
17751783
if (!chain || !address) {
1776-
res.status(400).send("Missing chain or address");
1777-
return;
1784+
throw new Error("Missing chain or address");
17781785
}
1786+
17791787
if (!CHAIN_PARAM_REGEX.test(chain)) {
1780-
res.status(400).send("Invalid chain parameter");
1781-
return;
1788+
throw new Error("Invalid chain parameter");
17821789
}
17831790

17841791
const normalizedChain = chain.toLowerCase();
17851792
const handler = CHAIN_HANDLERS[normalizedChain];
17861793

17871794
if (!handler) {
1788-
res.status(400).send("Unsupported chain");
1789-
return;
1795+
throw new Error("Unsupported chain");
17901796
}
17911797

1792-
const provider = parseBalanceProvider(req.query.provider);
1793-
1794-
// Ignore provider=chainz for non-BTC (but surface it for observability)
1798+
// Ignore provider=chainz for non-BTC (but log it)
17951799
if (provider === "chainz" && normalizedChain !== "btc") {
17961800
console.warn(`provider=chainz ignored for chain=${normalizedChain}`);
1797-
res.setHeader("x-provider-override-ignored", "true");
17981801
}
17991802

18001803
if (provider === "blockstream" && normalizedChain !== "btc") {
18011804
console.warn(`provider=blockstream ignored for chain=${normalizedChain}`);
1802-
res.setHeader("x-provider-override-ignored", "true");
18031805
}
18041806

1805-
// Slightly extend server timeouts for BTC path
1806-
if (normalizedChain === "btc") {
1807-
const extendedTimeout = BITCOIN_RPC_TIMEOUT_MS + 30_000;
1808-
if (typeof req.setTimeout === "function") req.setTimeout(extendedTimeout);
1809-
if (typeof res.setTimeout === "function") res.setTimeout(extendedTimeout);
1810-
}
1811-
1812-
const applyProviderHeaders = (response: ChainBalanceResponse) => {
1813-
res.setHeader("x-provider", response.provider);
1814-
if (
1815-
response.provider === "blockstream" &&
1816-
response.rateLimitRemaining !== undefined
1817-
) {
1818-
res.setHeader(
1819-
"x-blockstream-ratelimit-remaining",
1820-
String(response.rateLimitRemaining),
1821-
);
1822-
}
1823-
};
1824-
1825-
const sendBalanceResponse = (
1826-
responsePayload: ChainBalanceResponse,
1827-
fallbackReason?: string,
1828-
) => {
1829-
applyProviderHeaders(responsePayload);
1830-
if (fallbackReason) {
1831-
res.setHeader("x-fallback-reason", fallbackReason);
1832-
}
1833-
res.status(200).json(responsePayload);
1834-
};
1835-
1836-
const tryBlockstreamFallback = async (reason: string): Promise<boolean> => {
1807+
// Helper to try Blockstream fallback
1808+
const tryBlockstreamFallback = async (): Promise<ChainBalanceResponse | null> => {
18371809
try {
1838-
const balanceResponse = await fetchBlockstreamBalance(normalizedChain, address);
1839-
sendBalanceResponse(balanceResponse, reason);
1840-
return true;
1810+
return await fetchBlockstreamBalance(normalizedChain, address);
18411811
} catch (blockstreamErr) {
18421812
console.error("BTC fallback to Blockstream failed:", blockstreamErr);
1843-
return false;
1813+
return null;
18441814
}
18451815
};
18461816

1847-
const tryChainzFallback = async (reason: string): Promise<boolean> => {
1817+
// Helper to try Chainz fallback
1818+
const tryChainzFallback = async (): Promise<ChainBalanceResponse | null> => {
18481819
try {
1849-
const balanceResponse = await fetchChainzBalance(normalizedChain, address);
1850-
sendBalanceResponse(balanceResponse, reason);
1851-
return true;
1820+
return await fetchChainzBalance(normalizedChain, address);
18521821
} catch (fallbackErr) {
18531822
console.error("BTC fallback to Chainz failed:", fallbackErr);
1854-
return false;
1823+
return null;
18551824
}
18561825
};
18571826

1827+
// Per-chain address validation (when provided)
1828+
if (handler.validateAddress && !handler.validateAddress(address)) {
1829+
throw new Error("Invalid address format");
1830+
}
1831+
1832+
// If client explicitly asks for Chainz and it's BTC → go straight there
1833+
if (normalizedChain === "btc" && provider === "chainz") {
1834+
return await fetchChainzBalance(normalizedChain, address);
1835+
}
1836+
1837+
if (normalizedChain === "btc" && provider === "blockstream") {
1838+
return await fetchBlockstreamBalance(normalizedChain, address);
1839+
}
1840+
1841+
// Default path: Chainstack (wrap node discovery in try/catch)
1842+
let node: ChainstackNode | null = null;
18581843
try {
1859-
// Per-chain address validation (when provided)
1860-
if (handler.validateAddress && !handler.validateAddress(address)) {
1861-
res.status(400).send("Invalid address format");
1862-
return;
1863-
}
1844+
const nodes = await fetchChainstackNodes();
1845+
node = handler.selectNode(nodes);
1846+
} catch (fetchErr) {
1847+
if (normalizedChain === "btc") {
1848+
console.error(
1849+
"Fetching Chainstack nodes failed; falling back to alternative providers:",
1850+
fetchErr,
1851+
);
1852+
const blockstreamResult = await tryBlockstreamFallback();
1853+
if (blockstreamResult) return blockstreamResult;
18641854

1865-
// If client explicitly asks for Chainz and it's BTC → go straight there
1866-
if (normalizedChain === "btc" && provider === "chainz") {
1867-
const balanceResponse = await fetchChainzBalance(normalizedChain, address);
1868-
sendBalanceResponse(balanceResponse);
1869-
return;
1855+
const chainzResult = await tryChainzFallback();
1856+
if (chainzResult) return chainzResult;
18701857
}
1858+
throw fetchErr;
1859+
}
18711860

1872-
if (normalizedChain === "btc" && provider === "blockstream") {
1873-
const balanceResponse = await fetchBlockstreamBalance(normalizedChain, address);
1874-
sendBalanceResponse(balanceResponse);
1875-
return;
1876-
}
1861+
if (!node) {
1862+
if (normalizedChain === "btc") {
1863+
const blockstreamResult = await tryBlockstreamFallback();
1864+
if (blockstreamResult) return blockstreamResult;
18771865

1878-
// Default path: Chainstack (wrap node discovery in try/catch)
1879-
let node: ChainstackNode | null = null;
1880-
try {
1881-
const nodes = await fetchChainstackNodes();
1882-
node = handler.selectNode(nodes);
1883-
} catch (fetchErr) {
1884-
if (normalizedChain === "btc") {
1885-
console.error(
1886-
"Fetching Chainstack nodes failed; falling back to alternative providers:",
1887-
fetchErr,
1888-
);
1889-
if (await tryBlockstreamFallback("nodes-fetch-failed")) return;
1890-
if (await tryChainzFallback("nodes-fetch-failed")) return;
1891-
}
1892-
throw fetchErr;
1866+
const chainzResult = await tryChainzFallback();
1867+
if (chainzResult) return chainzResult;
18931868
}
1869+
throw new Error(`No Chainstack node available for ${normalizedChain}`);
1870+
}
18941871

1895-
if (!node) {
1896-
if (normalizedChain === "btc") {
1897-
if (await tryBlockstreamFallback("no-node")) return;
1898-
if (await tryChainzFallback("no-node")) return;
1899-
}
1900-
console.error(`No Chainstack node available for ${normalizedChain}`);
1901-
res.status(502).send("No Chainstack node available for requested chain");
1902-
return;
1872+
// Try Chainstack balance
1873+
try {
1874+
return await handler.fetchBalance(node, address);
1875+
} catch (err: any) {
1876+
// BTC: fallback on ANY error (timeout, unsupported RPC, etc.)
1877+
if (normalizedChain === "btc") {
1878+
console.error("BTC Chainstack path failed, attempting fallbacks:", {
1879+
code: err?.code,
1880+
message: err?.message,
1881+
});
1882+
1883+
const blockstreamResult = await tryBlockstreamFallback();
1884+
if (blockstreamResult) return blockstreamResult;
1885+
1886+
const chainzResult = await tryChainzFallback();
1887+
if (chainzResult) return chainzResult;
19031888
}
1889+
throw err;
1890+
}
1891+
};
19041892

1905-
// Try Chainstack balance
1906-
try {
1907-
const balanceResponse = await handler.fetchBalance(node, address);
1908-
res.setHeader("x-provider", "chainstack");
1909-
res.status(200).json(balanceResponse);
1910-
return;
1911-
} catch (err: any) {
1912-
// BTC: fallback on ANY error (timeout, unsupported RPC, etc.)
1913-
if (normalizedChain === "btc") {
1914-
console.error("BTC Chainstack path failed, attempting fallbacks:", {
1915-
code: err?.code,
1916-
message: err?.message,
1917-
});
1918-
const reason = String(err?.code || err?.message || "unknown");
1919-
if (await tryBlockstreamFallback(reason)) return;
1920-
if (await tryChainzFallback(reason)) return;
1921-
}
1922-
throw err;
1893+
export const balance = async (req: express.Request, res: express.Response) => {
1894+
const { chain, address } = req.params;
1895+
1896+
if (!chain || !address) {
1897+
res.status(400).send("Missing chain or address");
1898+
return;
1899+
}
1900+
1901+
const provider = parseBalanceProvider(req.query.provider);
1902+
1903+
// Slightly extend server timeouts for BTC path
1904+
const normalizedChain = chain.toLowerCase();
1905+
if (normalizedChain === "btc") {
1906+
const extendedTimeout = BITCOIN_RPC_TIMEOUT_MS + 30_000;
1907+
if (typeof req.setTimeout === "function") req.setTimeout(extendedTimeout);
1908+
if (typeof res.setTimeout === "function") res.setTimeout(extendedTimeout);
1909+
}
1910+
1911+
try {
1912+
const balanceResponse = await fetchChainBalance(chain, address, provider);
1913+
1914+
// Set response headers
1915+
res.setHeader("x-provider", balanceResponse.provider);
1916+
if (
1917+
balanceResponse.provider === "blockstream" &&
1918+
balanceResponse.rateLimitRemaining !== undefined
1919+
) {
1920+
res.setHeader(
1921+
"x-blockstream-ratelimit-remaining",
1922+
String(balanceResponse.rateLimitRemaining),
1923+
);
19231924
}
1925+
1926+
res.status(200).json(balanceResponse);
19241927
} catch (err) {
19251928
console.error("balance(): error while fetching chain balance", err);
19261929

src/server/handlers/wallet-api.ts

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import express from "express";
33
import { baseApiRequest, pipe, parseToken, getHoursDifferntial } from "../util";
44
import { fetchGlobalProps, getAccount } from "./hive-explorer";
55
import { apiRequest, ChainBalanceResponse } from "../helper";
6+
import { fetchChainBalance } from "./private-api";
67

78
import { EngineContracts, EngineIds, EngineMetric, EngineRequestPayload, EngineTables, JSON_RPC, Methods, Token, TokenBalance, TokenStatus } from "../../models/hiveEngine.types";
89
import { convertEngineToken, convertRewardsStatus } from "../../models/converters";
@@ -1643,6 +1644,35 @@ const buildSpkLayer = (spkData: any, marketData: any, currency: string): Portfol
16431644
return items;
16441645
};
16451646

1647+
// Helper to process items with concurrency limit
1648+
const processWithConcurrencyLimit = async <T, R>(
1649+
items: T[],
1650+
processor: (item: T) => Promise<R>,
1651+
concurrencyLimit: number
1652+
): Promise<R[]> => {
1653+
const results: R[] = [];
1654+
const executing: Set<Promise<void>> = new Set();
1655+
1656+
for (const [index, item] of items.entries()) {
1657+
const promise = processor(item)
1658+
.then((result) => {
1659+
results[index] = result;
1660+
})
1661+
.finally(() => {
1662+
executing.delete(promise);
1663+
});
1664+
1665+
executing.add(promise);
1666+
1667+
if (executing.size >= concurrencyLimit) {
1668+
await Promise.race(executing);
1669+
}
1670+
}
1671+
1672+
await Promise.all(Array.from(executing));
1673+
return results;
1674+
};
1675+
16461676
const buildChainLayer = async (
16471677
accountData: any,
16481678
marketData: any,
@@ -1655,39 +1685,24 @@ const buildChainLayer = async (
16551685
return [];
16561686
}
16571687

1658-
const items = await Promise.all(
1659-
wallets.map(async (wallet) => {
1688+
// Limit concurrent balance requests to 3 to avoid overwhelming the service
1689+
// and prevent 502 errors from proxies/load balancers
1690+
const items = await processWithConcurrencyLimit(
1691+
wallets,
1692+
async (wallet) => {
16601693
const chain = wallet.chain.toLowerCase();
16611694
const config = CHAIN_CONFIG[chain];
16621695
const decimals = wallet.decimals !== undefined ? wallet.decimals : config.decimals;
16631696

16641697
try {
1665-
// Use shorter timeout for portfolio balance queries (10s instead of default 30s)
1666-
// to prevent the entire portfolio request from timing out
1667-
const response = await apiRequest(
1668-
`balance/${chain}/${encodeURIComponent(wallet.address)}`,
1669-
"GET",
1670-
{},
1671-
{},
1672-
10000
1673-
);
1698+
// Call fetchChainBalance directly instead of making HTTP request
1699+
// This eliminates proxy overhead, double timeouts, and 502 errors
1700+
const data = await fetchChainBalance(chain, wallet.address);
16741701

1675-
if (response.status !== 200) {
1676-
const payload = response.data as any;
1677-
const message =
1678-
payload && typeof payload === "object"
1679-
? payload.error?.message || payload.error || payload.message
1680-
: typeof payload === "string"
1681-
? payload
1682-
: null;
1683-
throw new Error(message || `Chain balance request failed (${response.status})`);
1684-
}
1685-
1686-
const data = response.data as ChainBalanceResponse | undefined;
16871702
const balance = convertChainBalanceToAmount(data, decimals);
16881703
const price = getTokenPrice(marketData, wallet.symbol, currency);
16891704

1690-
const iconUrl = config.iconUrl || ASSET_ICON_URLS.CHAIN_PLACEHOLDER
1705+
const iconUrl = config.iconUrl || ASSET_ICON_URLS.CHAIN_PLACEHOLDER;
16911706

16921707
return makePortfolioItem(
16931708
wallet.name || config.name,
@@ -1718,7 +1733,8 @@ const buildChainLayer = async (
17181733
CHAIN_ACTIONS
17191734
);
17201735
}
1721-
}),
1736+
},
1737+
3 // Max 3 concurrent requests
17221738
);
17231739

17241740
return items.filter(Boolean);

0 commit comments

Comments
 (0)