diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e379cf4d..dfa5e247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: push: branches: [main] +permissions: + contents: read + jobs: ci: runs-on: ubuntu-latest diff --git a/.github/workflows/copilot-test.yml b/.github/workflows/copilot-test.yml index 6a1b8f5a..d67e28be 100644 --- a/.github/workflows/copilot-test.yml +++ b/.github/workflows/copilot-test.yml @@ -20,6 +20,9 @@ on: # branches: # - main +permissions: + contents: read + jobs: test-with-environment: runs-on: ubuntu-latest diff --git a/src/ai/flows/enhanced-tax-report-flow.ts b/src/ai/flows/enhanced-tax-report-flow.ts index 8f95c229..3d981c9c 100644 --- a/src/ai/flows/enhanced-tax-report-flow.ts +++ b/src/ai/flows/enhanced-tax-report-flow.ts @@ -9,7 +9,9 @@ */ import { z } from '@genkit-ai/core'; +import { VALID_CURRENCIES } from '@/lib/types'; import type { WalletData, Transaction, Currency } from '@/lib/types'; +import { fetchJson } from '@/lib/blockchain-api'; import { eachDayOfInterval, startOfDay, isWithinInterval, format, subDays, addDays, differenceInDays } from 'date-fns'; import { TaxCalculator, @@ -26,7 +28,7 @@ const EnhancedTaxReportInputSchema = z.object({ walletData: z.string().describe('JSON string of the full WalletData object.'), startDate: z.string().describe('Start date for the report in ISO 8601 format.'), endDate: z.string().describe('End date for the report in ISO 8601 format.'), - currency: z.string().describe('The currency for the report (e.g., "USD").'), + currency: z.enum(['USD', 'EUR', 'GBP']).describe('The currency for the report.'), accountingMethod: z.enum(['FIFO', 'LIFO', 'HIFO', 'SPEC_ID', 'AVG_COST', 'SHARED_POOL']).optional().default('FIFO'), jurisdiction: z.enum(['US', 'UK', 'CANADA', 'AUSTRALIA', 'GERMANY', 'OTHER']).optional().default('US'), }); @@ -131,26 +133,30 @@ const EnhancedTaxReportOutputSchema = z.object({ export type EnhancedTaxReportOutput = z.infer; async function getDailyPrices(startDate: Date, endDate: Date, currency: Currency): Promise> { + if (!(VALID_CURRENCIES as readonly string[]).includes(currency)) { + throw new Error('Invalid currency for price lookup.'); + } const prices: Record = {}; - + const today = startOfDay(new Date()); const maxAllowedStartDate = subDays(today, 364); - + if (startDate < maxAllowedStartDate) { const requestedDays = differenceInDays(endDate, startDate); const availableDays = differenceInDays(endDate, maxAllowedStartDate); - + if (requestedDays > 364) { console.warn(`CoinGecko API: Requested ${requestedDays} days of data, but only ${availableDays} days are available due to 364-day limit.`); } - + if (endDate < maxAllowedStartDate) { throw new Error(`CoinGecko API: Both dates are older than 364 days. Historical data is not available.`); } } - + const finalStartDate = startDate < maxAllowedStartDate ? maxAllowedStartDate : startDate; let currentStartDate = finalStartDate; + const currencyCode = currency.toLowerCase(); try { while (currentStartDate <= endDate) { @@ -162,19 +168,11 @@ async function getDailyPrices(startDate: Date, endDate: Date, currency: Currency const fromTimestamp = Math.floor(currentStartDate.getTime() / 1000); const toTimestamp = Math.floor(currentEndDate.getTime() / 1000) + 3600; - const url = `https://api.coingecko.com/api/v3/coins/bitcoin/market_chart/range?vs_currency=${currency.toLowerCase()}&from=${fromTimestamp}&to=${toTimestamp}`; - const headers: HeadersInit = { 'Accept': 'application/json' }; - const apiKey = process.env.COINGECKO_API_KEY; - if (apiKey) { - headers['x-cg-demo-api-key'] = apiKey; - } - - const response = await fetch(url, { headers, next: { revalidate: 3600 } }); - if (!response.ok) { - throw new Error(`Failed to fetch historical prices. Status: ${response.status}`); - } - - const data = await response.json(); + const data = await fetchJson('coingecko', '/api/v3/coins/bitcoin/market_chart/range', { + vs_currency: currencyCode, + from: String(fromTimestamp), + to: String(toTimestamp), + }, {}, 3600); for (const [timestamp, price] of data.prices) { const dateKey = format(startOfDay(new Date(timestamp)), 'yyyy-MM-dd'); diff --git a/src/ai/flows/tax-report-flow.ts b/src/ai/flows/tax-report-flow.ts index 1b208c0a..26c29cee 100644 --- a/src/ai/flows/tax-report-flow.ts +++ b/src/ai/flows/tax-report-flow.ts @@ -9,7 +9,9 @@ */ import { z } from '@genkit-ai/core'; +import { VALID_CURRENCIES } from '@/lib/types'; import type { WalletData, Transaction, Currency, Holding } from '@/lib/types'; +import { fetchJson } from '@/lib/blockchain-api'; import { eachDayOfInterval, startOfDay, isWithinInterval, format, subDays, addDays, differenceInDays } from 'date-fns'; // The input schema for the tax report flow @@ -17,7 +19,7 @@ const TaxReportInputSchema = z.object({ walletData: z.string().describe('JSON string of the full WalletData object.'), startDate: z.string().describe('Start date for the report in ISO 8601 format.'), endDate: z.string().describe('End date for the report in ISO 8601 format.'), - currency: z.string().describe('The currency for the report (e.g., "USD").'), + currency: z.enum(['USD', 'EUR', 'GBP']).describe('The currency for the report.'), }); export type TaxReportInput = z.infer; @@ -55,65 +57,51 @@ const TaxReportOutputSchema = z.object({ export type TaxReportOutput = z.infer; async function getDailyPrices(startDate: Date, endDate: Date, currency: Currency): Promise> { + if (!(VALID_CURRENCIES as readonly string[]).includes(currency)) { + throw new Error('Invalid currency for price lookup.'); + } const prices: Record = {}; - - // Ensure consistent date handling - use startOfDay for all date comparisons + const today = startOfDay(new Date()); - // Use 364 days instead of 365 to account for inclusive date counting in CoinGecko API const maxAllowedStartDate = subDays(today, 364); - - // Check if the requested range exceeds CoinGecko's 364-day limit + if (startDate < maxAllowedStartDate) { const requestedDays = differenceInDays(endDate, startDate); const availableDays = differenceInDays(endDate, maxAllowedStartDate); - + if (requestedDays > 364) { console.warn(`CoinGecko API: Requested ${requestedDays} days of data, but only ${availableDays} days are available due to 364-day limit.`); console.warn(`Data will be provided from ${format(maxAllowedStartDate, 'yyyy-MM-dd')} to ${format(endDate, 'yyyy-MM-dd')}`); } - - // If both dates are older than 364 days, throw an error + if (endDate < maxAllowedStartDate) { throw new Error(`CoinGecko API: Both start date (${format(startDate, 'yyyy-MM-dd')}) and end date (${format(endDate, 'yyyy-MM-dd')}) are older than 364 days. Historical data is not available for this range.`); } } - - // Strictly enforce the 364-day limit - never request data older than this + const finalStartDate = startDate < maxAllowedStartDate ? maxAllowedStartDate : startDate; - + console.log(`CoinGecko API: Requesting data from ${format(finalStartDate, 'yyyy-MM-dd')} to ${format(endDate, 'yyyy-MM-dd')}`); - + let currentStartDate = finalStartDate; + const currencyCode = currency.toLowerCase(); try { while (currentStartDate <= endDate) { - + let currentEndDate = addDays(currentStartDate, 90); if (currentEndDate > endDate) { currentEndDate = endDate; } const fromTimestamp = Math.floor(currentStartDate.getTime() / 1000); - // Add a small buffer to the end timestamp to ensure it's always greater, preventing API errors for single-day ranges. const toTimestamp = Math.floor(currentEndDate.getTime() / 1000) + 3600; - const url = `https://api.coingecko.com/api/v3/coins/bitcoin/market_chart/range?vs_currency=${currency.toLowerCase()}&from=${fromTimestamp}&to=${toTimestamp}`; - const headers: HeadersInit = { 'Accept': 'application/json' }; - const apiKey = process.env.COINGECKO_API_KEY; - if (apiKey) { - headers['x-cg-demo-api-key'] = apiKey; - } else { - console.warn("The CoinGecko API key is missing. Public API may have rate limits."); - } - - const response = await fetch(url, { headers, next: { revalidate: 3600 } }); - if (!response.ok) { - const errorBody = await response.text(); - console.error(`CoinGecko request for chunk ${format(currentStartDate, 'yyyy-MM-dd')} failed with status ${response.status}:`, errorBody); - throw new Error(`Failed to fetch historical prices from CoinGecko. Status: ${response.status}`); - } - - const data = await response.json(); + const data = await fetchJson('coingecko', '/api/v3/coins/bitcoin/market_chart/range', { + vs_currency: currencyCode, + from: String(fromTimestamp), + to: String(toTimestamp), + }, {}, 3600); for (const [timestamp, price] of data.prices) { const dateKey = format(startOfDay(new Date(timestamp)), 'yyyy-MM-dd'); diff --git a/src/lib/blockchain-api.ts b/src/lib/blockchain-api.ts index b668e7f7..724563a3 100644 --- a/src/lib/blockchain-api.ts +++ b/src/lib/blockchain-api.ts @@ -2,35 +2,70 @@ 'use server'; +import { VALID_CURRENCIES } from '@/lib/types'; import type { Transaction, AddressInfo, Currency } from '@/lib/types'; -const BLOCKSTREAM_API_BASE = 'https://blockstream.info/api'; -const MEMPOOL_SPACE_API_BASE = 'https://mempool.space/api'; +export type AllowedHost = 'blockstream' | 'mempool' | 'coingecko' | 'blockchain_info' | 'alternative_me'; -const ESPLORA_BASES = [BLOCKSTREAM_API_BASE, MEMPOOL_SPACE_API_BASE]; +const TRUSTED_ORIGINS: Record = { + blockstream: 'https://blockstream.info', + mempool: 'https://mempool.space', + coingecko: 'https://api.coingecko.com', + blockchain_info: 'https://blockchain.info', + alternative_me: 'https://api.alternative.me', +}; -const ALLOWED_HOSTS = new Set([ - 'blockstream.info', - 'mempool.space', - 'api.coingecko.com', - 'blockchain.info', - 'api.alternative.me', -]); +const ALLOWED_PATHS: Record = { + blockstream: [/^\/api\/[a-zA-Z0-9\-._~/]*$/], + mempool: [/^\/api\/[a-zA-Z0-9\-._~/]*$/], + coingecko: [/^\/api\/v3\/[a-zA-Z0-9\-._~/]*$/], + blockchain_info: [/^\/[a-zA-Z0-9\-._~/]*$/], + alternative_me: [/^\/[a-zA-Z0-9\-._~/]*$/], +}; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export async function fetchJson(url: string, options?: RequestInit, revalidate?: number): Promise { - let parsedUrl: URL; - try { - parsedUrl = new URL(url); - } catch { - throw new Error('Invalid provider URL.'); +function sanitizeAddress(address: string): string { + const trimmed = address.trim(); + if (!/^(?:[13][a-km-zA-HJ-NP-Z1-9]{24,33}|bc1[a-z0-9]{39,59})$/.test(trimmed)) { + throw new Error('The address you entered is not a valid Bitcoin address.'); + } + return encodeURIComponent(trimmed); +} + +function sanitizeTxid(txid: string): string { + const trimmed = txid.trim(); + if (!/^[a-fA-F0-9]{64}$/.test(trimmed)) { + throw new Error('The transaction ID you entered is not valid.'); + } + return encodeURIComponent(trimmed); +} + +export async function fetchJson( + host: AllowedHost, + pathname: string, + query?: Record, + options?: RequestInit, + revalidate?: number, +): Promise { + const origin = TRUSTED_ORIGINS[host]; + + const hostPathPolicies = ALLOWED_PATHS[host]; + if (!hostPathPolicies || !hostPathPolicies.some((rx) => rx.test(pathname))) { + throw new Error('Disallowed provider URL path.'); } - if (parsedUrl.protocol !== 'https:' || !ALLOWED_HOSTS.has(parsedUrl.hostname)) { - throw new Error('Disallowed provider URL.'); + const url = new URL(pathname, origin); + if (query) { + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + } + + if (url.origin !== origin || url.protocol !== 'https:') { + throw new Error('URL construction resulted in an unexpected origin.'); } const headers: Record = { @@ -39,7 +74,7 @@ export async function fetchJson(url: string, options?: RequestInit, revalidate?: ...(options?.headers as Record || {}), }; - if (url.includes('api.coingecko.com')) { + if (host === 'coingecko') { const apiKey = process.env.COINGECKO_API_KEY; if (apiKey) { headers['x-cg-demo-api-key'] = apiKey; @@ -47,25 +82,22 @@ export async function fetchJson(url: string, options?: RequestInit, revalidate?: } try { - const response = await fetch(url, { + const response = await fetch(url.toString(), { ...options, signal: AbortSignal.timeout(20000), headers, - next: { revalidate: revalidate ?? 60 } // Default to 1-minute cache + next: { revalidate: revalidate ?? 60 }, }); const textBody = await response.text(); - // Check for Blockstream's non-JSON notice page FIRST. if (textBody.includes("Blockstream Explorer API NOTICE")) { - // Signal to callers that this provider is temporarily unusable so they can fallback const err: any = new Error('ESPLORA_PROVIDER_NOTICE'); err.code = 'ESPLORA_PROVIDER_NOTICE'; throw err; } if (!response.ok) { - console.error(`API request to ${url} failed with status ${response.status}:`, textBody); - // Handle specific text errors from Blockstream + console.error(`API request to ${url.toString()} failed with status ${response.status}:`, textBody); if (textBody.toLowerCase().includes('invalid bitcoin address')) { throw new Error('The address you entered is not a valid Bitcoin address.'); } @@ -76,10 +108,9 @@ export async function fetchJson(url: string, options?: RequestInit, revalidate?: } try { - // If the response was OK and not a notice, it should be JSON. return JSON.parse(textBody); } catch (e) { - console.error(`Failed to parse JSON from ${url}:`, e); + console.error(`Failed to parse JSON from ${url.toString()}:`, e); throw new Error(`The data provider returned a malformed response.`); } @@ -91,6 +122,8 @@ export async function fetchJson(url: string, options?: RequestInit, revalidate?: } } +const ESPLORA_HOSTS: AllowedHost[] = ['blockstream', 'mempool']; + /** * Fetch an Esplora endpoint with retry and provider fallback (Blockstream -> mempool.space). * Path must start with '/'. @@ -98,27 +131,44 @@ export async function fetchJson(url: string, options?: RequestInit, revalidate?: export async function esploraGet(path: string, revalidate?: number): Promise { const attemptsPerProvider = 2; let lastError: any = null; - for (const base of ESPLORA_BASES) { - const url = `${base}${path}`; + + let sanitizedPath: string; + const addressMatch = path.match(/^\/address\/([^/]+)(\/.*)?$/); + const txMatch = path.match(/^\/tx\/([^/]+)$/); + if (addressMatch) { + const safeAddr = sanitizeAddress(addressMatch[1]); + const suffix = addressMatch[2]; + let safeSuffix = ''; + if (suffix === '/txs') safeSuffix = '/txs'; + else if (suffix === '/utxo') safeSuffix = '/utxo'; + else if (suffix) throw new Error('Disallowed address endpoint.'); + sanitizedPath = `/address/${safeAddr}${safeSuffix}`; + } else if (txMatch) { + const safeTxid = sanitizeTxid(txMatch[1]); + sanitizedPath = `/tx/${safeTxid}`; + } else if (path === '/blocks/tip/height') { + sanitizedPath = '/blocks/tip/height'; + } else { + throw new Error('Disallowed esplora path.'); + } + + const fullPath = `/api${sanitizedPath}`; + for (const host of ESPLORA_HOSTS) { for (let attempt = 0; attempt < attemptsPerProvider; attempt++) { try { - return await fetchJson(url, {}, revalidate); + return await fetchJson(host, fullPath, undefined, {}, revalidate); } catch (e: any) { lastError = e; - // If Blockstream served a notice, immediately break to try next provider if (e?.code === 'ESPLORA_PROVIDER_NOTICE') { break; } - // Backoff for network/5xx/timeout if (e?.message?.includes('timed out') || /5\d\d/.test(e?.message || '')) { await sleep(300 * (attempt + 1)); continue; } - // For other errors, retry once, then move on await sleep(150 * (attempt + 1)); } } - // Try next provider } throw lastError ?? new Error('Failed to fetch from any Esplora provider'); } @@ -127,30 +177,37 @@ export async function esploraGet(path: string, revalidate?: number): Promise(); export async function getHistoricalPrice(date: Date, currency: Currency): Promise { + if (!(VALID_CURRENCIES as readonly string[]).includes(currency)) return 0; const dateKey = `${date.toISOString().split('T')[0]}-${currency}`; if (priceCache.has(dateKey)) { return priceCache.get(dateKey)!; } - // This API is slow, so we don't use fetchJson's default timeout here. - const url = `https://blockchain.info/toapi?currency=${currency}&value=1&time=${date.getTime()}`; try { - const response = await fetch(url, { next: { revalidate: 86400 } }); // Cache for 1 day - const price = await response.json(); + const price = await fetchJson('blockchain_info', '/toapi', { + currency, + value: '1', + time: String(date.getTime()), + }, {}, 86400); if (typeof price === 'number' && price > 0) { priceCache.set(dateKey, price); } return price || 0; } catch (error) { console.error(`Failed to fetch historical price for ${dateKey}:`, error); - return 0; // Return 0 if the API call fails + return 0; } } export async function getHistoricalPriceRange(days: number, currency: Currency): Promise<[number, number][]> { + if (!(VALID_CURRENCIES as readonly string[]).includes(currency)) return []; const currencyCode = currency.toLowerCase(); - const url = `https://api.coingecko.com/api/v3/coins/bitcoin/market_chart?vs_currency=${currencyCode}&days=${days}&interval=daily`; + const safeDays = Math.max(1, Math.trunc(Number(days)) || 1); try { - const data = await fetchJson(url, {}, 3600); // Cache for 1 hour + const data = await fetchJson('coingecko', '/api/v3/coins/bitcoin/market_chart', { + vs_currency: currencyCode, + days: String(safeDays), + interval: 'daily', + }, {}, 3600); return data.prices || []; } catch (error) { console.error(`Failed to fetch historical price range for ${days} days:`, error); @@ -161,14 +218,13 @@ export async function getHistoricalPriceRange(days: number, currency: Currency): export async function getAddressData(address: string): Promise<{ data: { addressInfo: AddressInfo, transactions: Transaction[], btcPrice: number } | null; error: string | null; }> { try { - const addressUrl = `/address/${address}`; - const addressTxsUrl = `/address/${address}/txs`; - const tickerUrl = 'https://blockchain.info/ticker'; - + const safeAddress = sanitizeAddress(address); + const addressUrl = `/address/${safeAddress}`; + const addressTxsUrl = `/address/${safeAddress}/txs`; const [addressStats, txsData, btcTicker] = await Promise.all([ - esploraGet(addressUrl, 300), // Cache address stats for 5 mins + esploraGet(addressUrl, 300), esploraGet(addressTxsUrl, 300).catch(() => []), - fetchJson(tickerUrl, {}, 60), // Cache price for 1 min + fetchJson('blockchain_info', '/ticker', undefined, {}, 60), ]); if (!addressStats || !addressStats.chain_stats || addressStats.chain_stats.tx_count === 0) return { data: null, error: 'Could not fetch data for this address. It may not have any transaction history.' }; @@ -230,8 +286,9 @@ export async function getAddressData(address: string): Promise<{ data: { address export async function getTransactionData(txid: string): Promise<{ data: Transaction | null; error: string | null; }> { try { - const txUrl = `/tx/${txid}`; - const txData = await esploraGet(txUrl, 86400); // Cache confirmed tx for a day + const safeTxid = sanitizeTxid(txid); + const txUrl = `/tx/${safeTxid}`; + const txData = await esploraGet(txUrl, 86400); if (!txData) return { data: null, error: `Could not fetch data for this transaction ID (${txid}).` }; const latestBlockHeight = await esploraGet(`/blocks/tip/height`, 60); @@ -269,8 +326,9 @@ export async function getTransactionData(txid: string): Promise<{ data: Transact export async function getAddressStats(address: string): Promise<{ data: AddressInfo | null; error: string | null; }> { try { - const addressStatsUrl = `/address/${address}`; - const stats = await esploraGet(addressStatsUrl, 300); // Cache for 5 mins + const safeAddress = sanitizeAddress(address); + const addressStatsUrl = `/address/${safeAddress}`; + const stats = await esploraGet(addressStatsUrl, 300); if (!stats) return { data: null, error: 'Could not fetch stats for this address.' }; const addressInfo: AddressInfo = { diff --git a/src/lib/blockchain.ts b/src/lib/blockchain.ts index 13e15e28..6d97fdd4 100644 --- a/src/lib/blockchain.ts +++ b/src/lib/blockchain.ts @@ -672,7 +672,7 @@ export async function getWalletData(xpub: string, currency: Currency = 'USD'): P console.log(`[WalletData] Fetching fresh price data for ${currency}...`); let btcPrices: Record; try { - const fetchedPrices = await fetchJson('https://blockchain.info/ticker'); + const fetchedPrices = await fetchJson('blockchain_info', '/ticker'); btcPrices = normalizeBtcPrices(fetchedPrices); } catch (e) { console.warn('[WalletData] Failed to fetch BTC price data, continuing with zeroed prices.', e); @@ -759,14 +759,14 @@ export async function getWalletDataProgressive( // Fetch fresh price data early (needed for all updates) let btcPrices: Record; try { - const fetchedPrices = await fetchJson('https://blockchain.info/ticker'); + const fetchedPrices = await fetchJson('blockchain_info', '/ticker'); btcPrices = normalizeBtcPrices(fetchedPrices); } catch (e) { console.warn('[WalletData] Failed to fetch BTC price data, continuing with zeroed prices.', e); btcPrices = { ...DEFAULT_BTC_PRICES }; } - + // If we have cached data, show it first if (cachedSnapshot && onProgress) { const cachedWalletData = await assembleFinalWalletData(cachedSnapshot, btcPrices, currency); diff --git a/src/lib/market.ts b/src/lib/market.ts index 53297869..5e25c43c 100644 --- a/src/lib/market.ts +++ b/src/lib/market.ts @@ -1,32 +1,28 @@ 'use server'; +import { VALID_CURRENCIES } from '@/lib/types'; import type { MarketPageData, MarketData, MarketChartPoint, Currency, FearAndGreedIndex, CandlestickDataPoint } from '@/lib/types'; import { fetchJson } from './blockchain-api'; -const API_BASE = 'https://api.coingecko.com/api/v3'; - export async function getMarketPageData(range: string = '1', currency: Currency = 'USD'): Promise<{ data: MarketPageData | null; error: string | null; }> { try { + if (!(VALID_CURRENCIES as readonly string[]).includes(currency)) { + return { data: null, error: 'Invalid currency.' }; + } const currencyCode = currency.toLowerCase(); - const marketDataUrl = `${API_BASE}/coins/markets?vs_currency=${currencyCode}&ids=bitcoin`; - const fearAndGreedUrl = 'https://api.alternative.me/fng/?limit=1'; - - const daysForChart = range; - let revalidateInSeconds = 60; // Default: 1 minute for live data + const daysForChart = Math.max(1, parseInt(range, 10) || 1); - if (parseInt(range, 10) > 90) { - revalidateInSeconds = 3600; // Cache for 1 hour + let revalidateInSeconds = 60; + if (daysForChart > 90) { + revalidateInSeconds = 3600; } - const chartDataUrl = `${API_BASE}/coins/bitcoin/market_chart?vs_currency=${currencyCode}&days=${daysForChart}`; - const candlestickDataUrl = `${API_BASE}/coins/bitcoin/ohlc?vs_currency=${currencyCode}&days=${daysForChart}`; - const [marketDataResponse, chartDataResponse, candlestickDataResponse, fearAndGreedResponse] = await Promise.all([ - fetchJson(marketDataUrl, {}, 60), - fetchJson(chartDataUrl, {}, revalidateInSeconds), - fetchJson(candlestickDataUrl, {}, revalidateInSeconds), - fetchJson(fearAndGreedUrl, {}, 3600) + fetchJson('coingecko', '/api/v3/coins/markets', { vs_currency: currencyCode, ids: 'bitcoin' }, {}, 60), + fetchJson('coingecko', '/api/v3/coins/bitcoin/market_chart', { vs_currency: currencyCode, days: String(daysForChart) }, {}, revalidateInSeconds), + fetchJson('coingecko', '/api/v3/coins/bitcoin/ohlc', { vs_currency: currencyCode, days: String(daysForChart) }, {}, revalidateInSeconds), + fetchJson('alternative_me', '/fng/', { limit: '1' }, {}, 3600), ]); const marketInfo = marketDataResponse[0]; diff --git a/src/lib/mempool.ts b/src/lib/mempool.ts index 844d52b0..5ff9db53 100644 --- a/src/lib/mempool.ts +++ b/src/lib/mempool.ts @@ -6,21 +6,16 @@ import { fetchJson } from './blockchain-api'; export async function getMempoolData(): Promise<{ data: MempoolData | null; error: string | null; }> { try { - const recommendedFeesUrl = 'https://mempool.space/api/v1/fees/recommended'; - const mempoolBlocksUrl = 'https://mempool.space/api/v1/fees/mempool-blocks'; - const mempoolInfoUrl = 'https://mempool.space/api/mempool'; - const blocksUrl = 'https://mempool.space/api/blocks'; - const [ recommendedFees, mempoolBlocks, mempoolInfo, latestBlocks, ] = await Promise.all([ - fetchJson(recommendedFeesUrl, {}, 60), - fetchJson(mempoolBlocksUrl, {}, 60), - fetchJson(mempoolInfoUrl, {}, 60), - fetchJson(blocksUrl, {}, 60), + fetchJson('mempool', '/api/v1/fees/recommended', undefined, {}, 60), + fetchJson('mempool', '/api/v1/fees/mempool-blocks', undefined, {}, 60), + fetchJson('mempool', '/api/mempool', undefined, {}, 60), + fetchJson('mempool', '/api/blocks', undefined, {}, 60), ]); const networkFeeRate = recommendedFees?.fastestFee ?? 0; @@ -56,15 +51,13 @@ export async function getBlockDetails(hash: string, startIndex: number = 0): Pro return { data: null, error: 'The block hash you entered is not valid.' }; } - const safeStartIndex = Number.isInteger(startIndex) && startIndex >= 0 ? startIndex : 0; + const n = Number(startIndex); + const safeStartIndex = Number.isFinite(n) && n >= 0 ? Math.trunc(n) : 0; const encodedHash = encodeURIComponent(normalizedHash); - const blockUrl = `https://mempool.space/api/block/${encodedHash}`; - const txsUrl = `https://mempool.space/api/block/${encodedHash}/txs/${safeStartIndex}`; - - // Block data is immutable, so we can cache it for a long time. + const [blockData, blockTxsData] = await Promise.all([ - fetchJson(blockUrl, {}, 86400), - fetchJson(txsUrl, {}, 86400).catch(() => []) + fetchJson('mempool', `/api/block/${encodedHash}`, undefined, {}, 86400), + fetchJson('mempool', `/api/block/${encodedHash}/txs/${safeStartIndex}`, undefined, {}, 86400).catch(() => []) ]); if (!blockData) { diff --git a/src/lib/types.ts b/src/lib/types.ts index 05f3e0e1..b7d9f922 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -230,7 +230,8 @@ export type BlockDetails = LatestBlock & { transactions: BlockTransaction[]; }; -export type Currency = 'USD' | 'EUR' | 'GBP'; +export const VALID_CURRENCIES = ['USD', 'EUR', 'GBP'] as const; +export type Currency = (typeof VALID_CURRENCIES)[number]; // Enhanced Bitcoin-specific types for GPT-4.1 Mini analysis export type BitcoinTransactionType = 'send' | 'receive' | 'self-transfer' | 'exchange' | 'unknown';