add simple api key rotation (#782)

This commit is contained in:
Jakub
2025-08-27 19:28:59 +09:00
committed by GitHub
parent 8467e461f1
commit 6598026a86
7 changed files with 169 additions and 25 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@status-im/wallet": patch
"api": patch
---
add simple api key rotation
+2
View File
@@ -17,6 +17,8 @@
INFURA_API_KEY=""
ALCHEMY_API_KEY=""
ALCHEMY_API_KEYS=""
COINGECKO_API_KEY=""
CRYPTOCOMPARE_API_KEY=""
CRYPTOCOMPARE_API_KEYS=""
MERCURYO_SECRET_KEY=""
+2
View File
@@ -1,5 +1,7 @@
INFURA_API_KEY=""
ALCHEMY_API_KEY=""
ALCHEMY_API_KEYS=""
COINGECKO_API_KEY=""
CRYPTOCOMPARE_API_KEY=""
CRYPTOCOMPARE_API_KEYS=""
MERCURYO_SECRET_KEY=""
+2 -2
View File
@@ -9,9 +9,9 @@ if (typeof window !== 'undefined') {
export const envSchema = z.object({
INFURA_API_KEY: z.string(),
ALCHEMY_API_KEY: z.string(),
ALCHEMY_API_KEYS: z.string(),
COINGECKO_API_KEY: z.string(),
CRYPTOCOMPARE_API_KEY: z.string(),
CRYPTOCOMPARE_API_KEYS: z.string(),
PORT: z.coerce.number().optional(),
MERCURYO_SECRET_KEY: z.string(),
})
@@ -11,6 +11,11 @@ import retry from 'async-retry'
import { formatEther } from 'ethers'
import { serverEnv } from '../../../config/env.server.mjs'
import {
getRandomApiKey,
markApiKeyAsRateLimited,
markApiKeyAsSuccessful,
} from '../api-key-rotation'
import { getNativeTokenPrice } from '../coingecko'
import { estimateConfirmationTime, processFeeHistory } from './utils'
@@ -94,7 +99,7 @@ export async function getNativeTokenBalance(
network: NetworkType,
) {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const body = await _retry(async () =>
_fetch<NativeTokenBalanceResponseBody>(url, 'POST', 0, {
@@ -125,7 +130,7 @@ export async function getERC20TokensBalance(
throw new Error('Too many contracts')
}
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const params: (string | string[])[] =
@@ -160,7 +165,7 @@ export async function getTokensBalance(
pageKey?: string,
) {
const url = new URL(
`https://api.g.alchemy.com/data/v2/${serverEnv.ALCHEMY_API_KEY}/assets/tokens/by-address`,
`https://api.g.alchemy.com/data/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}/assets/tokens/by-address`,
)
const body = await _retry(async () =>
@@ -198,7 +203,7 @@ export async function fetchTokenBalanceHistory(
{
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const body = await _retry(async () =>
@@ -228,7 +233,7 @@ export async function fetchTokenBalanceHistory(
{
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const body = await _retry(async () =>
@@ -393,7 +398,7 @@ export async function getNFTs(
pageSize?: string,
) {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${serverEnv.ALCHEMY_API_KEY}/getNFTsForOwner`,
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}/getNFTsForOwner`,
)
url.searchParams.set('owner', address)
url.searchParams.set('withMetadata', 'true')
@@ -418,7 +423,7 @@ export async function getNFTMetadata(
network: NetworkType,
) {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${serverEnv.ALCHEMY_API_KEY}/getNFTMetadata`,
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}/getNFTMetadata`,
)
url.searchParams.set('contractAddress', contract)
url.searchParams.set('tokenId', tokenId)
@@ -433,7 +438,7 @@ export async function getNFTMetadata(
export async function getLatestBlockNumber(
network: NetworkType,
): Promise<number> {
const url = `https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`
const url = `https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`
const body = await _retry(async () =>
_fetch<BlockNumberResponseBody>(new URL(url), 'POST', 0, {
@@ -469,7 +474,7 @@ export async function getOutgoingAssetTransfers(
)
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const params: {
@@ -554,7 +559,7 @@ export async function getIncomingAssetTransfers(
)
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const params: {
@@ -780,7 +785,7 @@ export async function getTransactionStatus(
network: NetworkType,
): Promise<'pending' | 'success' | 'failed' | 'unknown'> {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const body = await _retry(async () =>
@@ -818,7 +823,7 @@ export async function deprecated_getNFTSale(
network: NetworkType,
) {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${serverEnv.ALCHEMY_API_KEY}/getNFTSales`,
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}/getNFTSales`,
)
url.searchParams.set('contractAddress', contract)
url.searchParams.set('tokenId', tokenId)
@@ -843,7 +848,7 @@ export async function deprecated_getNFTSale(
*/
export async function getNFTFloorPrice(contract: string, network: NetworkType) {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${serverEnv.ALCHEMY_API_KEY}/getFloorPrice`,
`https://${alchemyNetworks[network]}.g.alchemy.com/nft/v3/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}/getFloorPrice`,
)
url.searchParams.set('contractAddress', contract)
@@ -864,7 +869,7 @@ export async function getFeeRate(
},
) {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const gasParams: Record<string, string> = {
@@ -966,7 +971,7 @@ export async function broadcastTransaction(
network: NetworkType = 'ethereum',
) {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const body = await _retry(async () =>
@@ -989,7 +994,7 @@ export async function getTransactionCount(
network: NetworkType,
) {
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${getRandomApiKey(serverEnv.ALCHEMY_API_KEYS)}`,
)
const body = await _retry(async () =>
@@ -1006,6 +1011,17 @@ export async function getTransactionCount(
return nonce
}
function extractAlchemyApiKey(url: URL): string | undefined {
const pathParts = url.pathname.split('/')
if (pathParts.includes('data')) {
return pathParts[3]
} else if (pathParts.includes('nft')) {
return pathParts[3]
} else {
return pathParts[2]
}
}
async function _fetch<T extends ResponseBody>(
url: URL,
method: 'GET' | 'POST',
@@ -1030,12 +1046,43 @@ async function _fetch<T extends ResponseBody>(
if (!response.ok) {
console.error(response.statusText)
if (response.status === 429) {
const apiKey = extractAlchemyApiKey(url)
if (apiKey) {
markApiKeyAsRateLimited(apiKey)
}
}
// todo: https://trpc.io/docs/v10/server/error-handling#throwing-errors for passing original error as `cause`
throw new Error('Failed to fetch.', { cause: response.status })
}
const _body: T = await response.json()
if (
typeof _body === 'object' &&
_body !== null &&
'error' in _body &&
_body.error
) {
const error = _body.error as { code?: number; message?: string }
if (
error.code === 429 ||
error.message?.toLowerCase().includes('rate limit')
) {
const apiKey = extractAlchemyApiKey(url)
if (apiKey) {
markApiKeyAsRateLimited(apiKey)
}
}
} else {
const apiKey = extractAlchemyApiKey(url)
if (apiKey) {
markApiKeyAsSuccessful(apiKey)
}
}
return _body
}
@@ -0,0 +1,39 @@
const blockedApiKeys = new Map<string, { count: number; until: number }>()
export function getRandomApiKey(keys: string): string {
const list = keys
.split(',')
.map(k => k.trim())
.filter(Boolean)
if (!list.length) throw new Error('Missing API keys.')
const now = Date.now()
const availableApiKeys = list.filter(key => {
const info = blockedApiKeys.get(key)
if (info && now < info.until) {
return false
}
return true
})
if (!availableApiKeys.length) {
return list[0] // fallback
}
return availableApiKeys[Math.floor(Math.random() * availableApiKeys.length)]
}
export function markApiKeyAsRateLimited(key: string): void {
const existing = blockedApiKeys.get(key)
const count = existing ? existing.count + 1 : 1
const minutes = Math.min(Math.pow(2, count - 1), 15)
const until = Date.now() + minutes * 60 * 1000
blockedApiKeys.set(key, { count, until })
}
export function markApiKeyAsSuccessful(key: string): void {
if (blockedApiKeys.has(key)) {
blockedApiKeys.delete(key)
}
}
@@ -11,6 +11,11 @@
// import 'server-only'
import { serverEnv } from '../../../config/env.server.mjs'
import {
getRandomApiKey,
markApiKeyAsRateLimited,
markApiKeyAsSuccessful,
} from '../api-key-rotation'
import type {
deprecated_TokensMetadataResponseBody,
@@ -36,7 +41,10 @@ export async function legacy_fetchTokenPriceHistory(
url.searchParams.set('tsym', 'USD')
url.searchParams.set('allData', 'true')
url.searchParams.set('tryConversion', 'false')
url.searchParams.set('api_key', serverEnv.CRYPTOCOMPARE_API_KEY)
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<legacy_TokenPriceHistoryResponseBody>(url, 3600)
const data = body.Data.Data
@@ -57,7 +65,10 @@ export async function legacy_fetchTokenPriceHistory(
url.searchParams.set('toTs', to.toString())
url.searchParams.set('limit', '2000')
url.searchParams.set('tryConversion', 'false')
url.searchParams.set('api_key', serverEnv.CRYPTOCOMPARE_API_KEY)
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<legacy_TokenPriceHistoryResponseBody>(url, 3600)
const data = body.Data.Data
@@ -84,7 +95,10 @@ export async function fetchTokenMetadata(symbol: string) {
url.searchParams.set('asset', symbol)
url.searchParams.set('asset_lookup_priority', 'SYMBOL')
url.searchParams.set('quote_asset', 'USD')
url.searchParams.set('api_key', serverEnv.CRYPTOCOMPARE_API_KEY)
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<TokenMetadataResponseBody>(url, 3600)
const data = body.Data
@@ -101,7 +115,10 @@ export async function deprecated_fetchTokenMetadata(symbol: string) {
'https://data-api.cryptocompare.com/asset/v1/data/by/symbol',
)
url.searchParams.set('asset_symbol', symbol)
url.searchParams.set('api_key', serverEnv.CRYPTOCOMPARE_API_KEY)
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<deprecated_TokensMetadataResponseBody>(url, 3600)
const data = body.Data[symbol]
@@ -118,7 +135,10 @@ export async function deprecated_fetchTokenMetadata(symbol: string) {
export async function legacy_research_fetchTokenMetadata(symbol: string) {
const url = new URL('https://min-api.cryptocompare.com/data/all/coinlist')
url.searchParams.set('fsym', symbol)
url.searchParams.set('api_key', serverEnv.CRYPTOCOMPARE_API_KEY)
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<legacy_research_TokenMetadataResponseBody>(
url,
@@ -138,7 +158,10 @@ export async function legacy_fetchTokensPrice(symbols: string[]) {
url.searchParams.set('fsyms', symbols.join(','))
url.searchParams.set('tsyms', 'USD')
url.searchParams.set('relaxedValidation', 'true')
url.searchParams.set('api_key', serverEnv.CRYPTOCOMPARE_API_KEY)
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
try {
const body = await _fetch<legacy_TokensPriceResponseBody>(url, 15)
@@ -186,7 +209,10 @@ export async function fetchTokensPriceForDate(
url.searchParams.set('toTs', timestamp.toString())
url.searchParams.set('limit', '1')
url.searchParams.set('tryConversion', 'false')
url.searchParams.set('api_key', serverEnv.CRYPTOCOMPARE_API_KEY)
url.searchParams.set(
'api_key',
getRandomApiKey(serverEnv.CRYPTOCOMPARE_API_KEYS),
)
const body = await _fetch<legacy_TokenPriceHistoryResponseBody>(url, 15)
const prices = body.Data.Data
@@ -225,6 +251,14 @@ async function _fetch<
if (!response.ok) {
console.error(response.statusText)
if (response.status === 429) {
const apiKey = url.searchParams.get('api_key')
if (apiKey) {
markApiKeyAsRateLimited(apiKey)
}
}
throw new Error('Failed to fetch.')
}
@@ -232,8 +266,22 @@ async function _fetch<
if ('Response' in body && body.Response !== 'Success') {
console.error(body.Message)
const message = 'Message' in body ? String(body.Message).toLowerCase() : ''
if (message.includes('rate limit')) {
const apiKey = url.searchParams.get('api_key')
if (apiKey) {
markApiKeyAsRateLimited(apiKey)
}
}
throw new Error('Failed to fetch.')
}
const apiKey = url.searchParams.get('api_key')
if (apiKey) {
markApiKeyAsSuccessful(apiKey)
}
return body
}