|
8 | 8 | apiRequest, |
9 | 9 | getPromotedEntries, |
10 | 10 | ChainBalanceResponse, |
| 11 | + BalanceProvider, |
11 | 12 | parseBalanceProvider, |
12 | 13 | fetchChainzBalance, |
13 | 14 | fetchBlockstreamBalance, |
@@ -1769,158 +1770,160 @@ const CHAIN_BROADCAST_HANDLERS: Record<string, ChainBroadcastHandler> = { |
1769 | 1770 | }, |
1770 | 1771 | }; |
1771 | 1772 |
|
1772 | | -export const balance = async (req: express.Request, res: express.Response) => { |
1773 | | - const { chain, address } = req.params; |
1774 | 1773 |
|
| 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> => { |
1775 | 1783 | if (!chain || !address) { |
1776 | | - res.status(400).send("Missing chain or address"); |
1777 | | - return; |
| 1784 | + throw new Error("Missing chain or address"); |
1778 | 1785 | } |
| 1786 | + |
1779 | 1787 | if (!CHAIN_PARAM_REGEX.test(chain)) { |
1780 | | - res.status(400).send("Invalid chain parameter"); |
1781 | | - return; |
| 1788 | + throw new Error("Invalid chain parameter"); |
1782 | 1789 | } |
1783 | 1790 |
|
1784 | 1791 | const normalizedChain = chain.toLowerCase(); |
1785 | 1792 | const handler = CHAIN_HANDLERS[normalizedChain]; |
1786 | 1793 |
|
1787 | 1794 | if (!handler) { |
1788 | | - res.status(400).send("Unsupported chain"); |
1789 | | - return; |
| 1795 | + throw new Error("Unsupported chain"); |
1790 | 1796 | } |
1791 | 1797 |
|
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) |
1795 | 1799 | if (provider === "chainz" && normalizedChain !== "btc") { |
1796 | 1800 | console.warn(`provider=chainz ignored for chain=${normalizedChain}`); |
1797 | | - res.setHeader("x-provider-override-ignored", "true"); |
1798 | 1801 | } |
1799 | 1802 |
|
1800 | 1803 | if (provider === "blockstream" && normalizedChain !== "btc") { |
1801 | 1804 | console.warn(`provider=blockstream ignored for chain=${normalizedChain}`); |
1802 | | - res.setHeader("x-provider-override-ignored", "true"); |
1803 | 1805 | } |
1804 | 1806 |
|
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> => { |
1837 | 1809 | try { |
1838 | | - const balanceResponse = await fetchBlockstreamBalance(normalizedChain, address); |
1839 | | - sendBalanceResponse(balanceResponse, reason); |
1840 | | - return true; |
| 1810 | + return await fetchBlockstreamBalance(normalizedChain, address); |
1841 | 1811 | } catch (blockstreamErr) { |
1842 | 1812 | console.error("BTC fallback to Blockstream failed:", blockstreamErr); |
1843 | | - return false; |
| 1813 | + return null; |
1844 | 1814 | } |
1845 | 1815 | }; |
1846 | 1816 |
|
1847 | | - const tryChainzFallback = async (reason: string): Promise<boolean> => { |
| 1817 | + // Helper to try Chainz fallback |
| 1818 | + const tryChainzFallback = async (): Promise<ChainBalanceResponse | null> => { |
1848 | 1819 | try { |
1849 | | - const balanceResponse = await fetchChainzBalance(normalizedChain, address); |
1850 | | - sendBalanceResponse(balanceResponse, reason); |
1851 | | - return true; |
| 1820 | + return await fetchChainzBalance(normalizedChain, address); |
1852 | 1821 | } catch (fallbackErr) { |
1853 | 1822 | console.error("BTC fallback to Chainz failed:", fallbackErr); |
1854 | | - return false; |
| 1823 | + return null; |
1855 | 1824 | } |
1856 | 1825 | }; |
1857 | 1826 |
|
| 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; |
1858 | 1843 | 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; |
1864 | 1854 |
|
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; |
1870 | 1857 | } |
| 1858 | + throw fetchErr; |
| 1859 | + } |
1871 | 1860 |
|
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; |
1877 | 1865 |
|
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; |
1893 | 1868 | } |
| 1869 | + throw new Error(`No Chainstack node available for ${normalizedChain}`); |
| 1870 | + } |
1894 | 1871 |
|
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; |
1903 | 1888 | } |
| 1889 | + throw err; |
| 1890 | + } |
| 1891 | +}; |
1904 | 1892 |
|
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 | + ); |
1923 | 1924 | } |
| 1925 | + |
| 1926 | + res.status(200).json(balanceResponse); |
1924 | 1927 | } catch (err) { |
1925 | 1928 | console.error("balance(): error while fetching chain balance", err); |
1926 | 1929 |
|
|
0 commit comments