mirror of
https://github.com/status-im/status-web.git
synced 2026-08-31 14:21:18 +00:00
add simple api key rotation (#782)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@status-im/wallet": patch
|
||||
"api": patch
|
||||
---
|
||||
|
||||
add simple api key rotation
|
||||
@@ -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=""
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user