From 5e783038de88c7274372bc2d48d65fb5ce4a5410 Mon Sep 17 00:00:00 2001 From: o-az Date: Mon, 27 Apr 2026 19:02:31 -0700 Subject: [PATCH 1/4] chore: use rate limit API directly instead of DO --- apps/explorer/src/index.server.ts | 3 - .../src/lib/server/export-rate-limit.ts | 138 ++---------------- .../routes/api/address/balances/$address.ts | 2 +- .../routes/api/address/history/$address.ts | 2 +- apps/explorer/wrangler.json | 60 ++++---- 5 files changed, 42 insertions(+), 163 deletions(-) diff --git a/apps/explorer/src/index.server.ts b/apps/explorer/src/index.server.ts index b362a49b2..182182f3a 100644 --- a/apps/explorer/src/index.server.ts +++ b/apps/explorer/src/index.server.ts @@ -1,8 +1,5 @@ import * as Sentry from '@sentry/cloudflare' import handler, { createServerEntry } from '@tanstack/react-start/server-entry' -import { ExplorerExportRateLimit } from '#lib/server/export-rate-limit' - -export { ExplorerExportRateLimit } export const redirects: Array<{ from: RegExp diff --git a/apps/explorer/src/lib/server/export-rate-limit.ts b/apps/explorer/src/lib/server/export-rate-limit.ts index 2e0fda861..b40e3334c 100644 --- a/apps/explorer/src/lib/server/export-rate-limit.ts +++ b/apps/explorer/src/lib/server/export-rate-limit.ts @@ -1,28 +1,13 @@ -import { DurableObject, env } from 'cloudflare:workers' -import type { Address } from 'ox' +import { env } from 'cloudflare:workers' import { getRequestHeader } from '@tanstack/react-start/server' -const RATE_LIMIT_WINDOW_MS = 10 * 60_000 -const RATE_LIMITS = { - ip: 20, - wallet: 5, -} as const - -type RateLimitBucket = { - count: number - resetAt: number -} +const RATE_LIMIT_PERIOD_SECONDS = 60 type RateLimitResult = { allowed: boolean - remaining: number retryAfterSeconds: number } -type ExportRateLimitEnv = Cloudflare.Env & { - EXPLORER_EXPORT_RATE_LIMIT?: DurableObjectNamespace -} - export class RateLimitExceededError extends Error { retryAfterSeconds: number @@ -33,70 +18,6 @@ export class RateLimitExceededError extends Error { } } -export class ExplorerExportRateLimit extends DurableObject { - async fetch(request: Request): Promise { - const body = (await request.json()) as { - limit?: number - windowMs?: number - } - - const limit = - typeof body.limit === 'number' && Number.isFinite(body.limit) - ? Math.floor(body.limit) - : 0 - const windowMs = - typeof body.windowMs === 'number' && Number.isFinite(body.windowMs) - ? Math.floor(body.windowMs) - : 0 - - if (limit < 1 || windowMs < 1) { - return Response.json( - { error: 'Invalid export rate limit request' }, - { status: 400 }, - ) - } - - const now = Date.now() - const existing = await this.ctx.storage.get('bucket') - const bucket = - existing && existing.resetAt > now - ? existing - : { count: 0, resetAt: now + windowMs } - - if (bucket.count >= limit) { - return Response.json({ - allowed: false, - remaining: 0, - retryAfterSeconds: Math.max( - 1, - Math.ceil((bucket.resetAt - now) / 1000), - ), - } satisfies RateLimitResult) - } - - bucket.count += 1 - await this.ctx.storage.put('bucket', bucket) - await this.ctx.storage.setAlarm(bucket.resetAt) - - return Response.json({ - allowed: true, - remaining: Math.max(0, limit - bucket.count), - retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), - } satisfies RateLimitResult) - } - - async alarm(): Promise { - const bucket = await this.ctx.storage.get('bucket') - if (!bucket || bucket.resetAt > Date.now()) return - - await this.ctx.storage.deleteAll() - } -} - -function getRateLimitNamespace(): DurableObjectNamespace | null { - return (env as ExportRateLimitEnv).EXPLORER_EXPORT_RATE_LIMIT ?? null -} - function getClientIp(): string | null { const cfConnectingIp = getRequestHeader('cf-connecting-ip')?.trim() if (cfConnectingIp) return cfConnectingIp @@ -114,65 +35,36 @@ function getClientIp(): string | null { } async function consumeRateLimit(params: { + limiter: RateLimit key: string - limit: number - windowMs: number }): Promise { - const namespace = getRateLimitNamespace() - if (!namespace) { + if (!params.limiter) return { allowed: true, - remaining: params.limit, retryAfterSeconds: 0, } - } - const id = namespace.idFromName(params.key) - const stub = namespace.get(id) - const response = await stub.fetch('https://rate-limit/consume', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ limit: params.limit, windowMs: params.windowMs }), - }) + const { success } = await params.limiter.limit({ key: params.key }) - if (!response.ok) { - throw new Error('Failed to check export rate limit') + return { + allowed: success, + retryAfterSeconds: success ? 0 : RATE_LIMIT_PERIOD_SECONDS, } - - return (await response.json()) as RateLimitResult } -export async function enforceCsvExportRateLimit( - address: Address.Address, -): Promise { +export async function enforceCsvExportRateLimit(): Promise { const ip = getClientIp() if (!ip) return - const normalizedAddress = address.toLowerCase() - const [ipResult, walletResult] = await Promise.all([ - consumeRateLimit({ - key: `csv-export:ip:${ip}`, - limit: RATE_LIMITS.ip, - windowMs: RATE_LIMIT_WINDOW_MS, - }), - consumeRateLimit({ - key: `csv-export:ip:${ip}:wallet:${normalizedAddress}`, - limit: RATE_LIMITS.wallet, - windowMs: RATE_LIMIT_WINDOW_MS, - }), - ]) + const result = await consumeRateLimit({ + limiter: env.EXPLORER_EXPORT_RATE_LIMIT, + key: `csv-export:ip:${ip}`, + }) - if (!ipResult.allowed) { + if (!result.allowed) { throw new RateLimitExceededError( 'Too many CSV exports from this IP. Please wait a few minutes before trying again.', - ipResult.retryAfterSeconds, - ) - } - - if (!walletResult.allowed) { - throw new RateLimitExceededError( - 'Too many CSV exports for this wallet from your IP. Please wait a few minutes before trying again.', - walletResult.retryAfterSeconds, + result.retryAfterSeconds, ) } } diff --git a/apps/explorer/src/routes/api/address/balances/$address.ts b/apps/explorer/src/routes/api/address/balances/$address.ts index 130043dd9..efb9b2d28 100644 --- a/apps/explorer/src/routes/api/address/balances/$address.ts +++ b/apps/explorer/src/routes/api/address/balances/$address.ts @@ -30,7 +30,7 @@ export const Route = createFileRoute('/api/address/balances/$address')({ const config = getWagmiConfig() const chainId = getChainId(config) if (isCsvExport) { - await enforceCsvExportRateLimit(address) + await enforceCsvExportRateLimit() } const response = await fetchAddressBalancesData({ address, diff --git a/apps/explorer/src/routes/api/address/history/$address.ts b/apps/explorer/src/routes/api/address/history/$address.ts index 7ccf4d0a6..f2349781e 100644 --- a/apps/explorer/src/routes/api/address/history/$address.ts +++ b/apps/explorer/src/routes/api/address/history/$address.ts @@ -61,7 +61,7 @@ export const Route = createFileRoute('/api/address/history/$address')({ const config = getWagmiConfig() const chainId = getChainId(config) if (isCsvExport) { - await enforceCsvExportRateLimit(address) + await enforceCsvExportRateLimit() const transactions = await fetchAddressHistoryExportRows({ address, chainId, diff --git a/apps/explorer/wrangler.json b/apps/explorer/wrangler.json index 21f67bfbc..ca121c724 100644 --- a/apps/explorer/wrangler.json +++ b/apps/explorer/wrangler.json @@ -16,18 +16,11 @@ "preview_id": "0211859b856e40d095ab4e2a5329a22d" } ], - "durable_objects": { - "bindings": [ - { - "name": "EXPLORER_EXPORT_RATE_LIMIT", - "class_name": "ExplorerExportRateLimit" - } - ] - }, - "migrations": [ + "ratelimits": [ { - "tag": "v1", - "new_sqlite_classes": ["ExplorerExportRateLimit"] + "name": "EXPLORER_EXPORT_RATE_LIMIT", + "namespace_id": "421701", + "simple": { "limit": 20, "period": 60 } } ], "browser": { "binding": "BROWSER" }, @@ -41,14 +34,13 @@ "preview_id": "0211859b856e40d095ab4e2a5329a22d" } ], - "durable_objects": { - "bindings": [ - { - "name": "EXPLORER_EXPORT_RATE_LIMIT", - "class_name": "ExplorerExportRateLimit" - } - ] - }, + "ratelimits": [ + { + "name": "EXPLORER_EXPORT_RATE_LIMIT", + "namespace_id": "421701", + "simple": { "limit": 20, "period": 60 } + } + ], "routes": [ { "custom_domain": true, @@ -97,14 +89,13 @@ "preview_id": "0211859b856e40d095ab4e2a5329a22d" } ], - "durable_objects": { - "bindings": [ - { - "name": "EXPLORER_EXPORT_RATE_LIMIT", - "class_name": "ExplorerExportRateLimit" - } - ] - }, + "ratelimits": [ + { + "name": "EXPLORER_EXPORT_RATE_LIMIT", + "namespace_id": "421701", + "simple": { "limit": 20, "period": 60 } + } + ], "routes": [ { "custom_domain": true, @@ -148,14 +139,13 @@ "preview_id": "0211859b856e40d095ab4e2a5329a22d" } ], - "durable_objects": { - "bindings": [ - { - "name": "EXPLORER_EXPORT_RATE_LIMIT", - "class_name": "ExplorerExportRateLimit" - } - ] - }, + "ratelimits": [ + { + "name": "EXPLORER_EXPORT_RATE_LIMIT", + "namespace_id": "421701", + "simple": { "limit": 20, "period": 60 } + } + ], "routes": [ { "custom_domain": true, From c7d1a8d99e1723e9e938b0a259d740f99256d070 Mon Sep 17 00:00:00 2001 From: o-az Date: Mon, 27 Apr 2026 19:10:38 -0700 Subject: [PATCH 2/4] fix: delete explorer export rate limit durable object --- apps/explorer/wrangler.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/explorer/wrangler.json b/apps/explorer/wrangler.json index ca121c724..e57f3a36c 100644 --- a/apps/explorer/wrangler.json +++ b/apps/explorer/wrangler.json @@ -23,6 +23,16 @@ "simple": { "limit": 20, "period": 60 } } ], + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["ExplorerExportRateLimit"] + }, + { + "tag": "v2", + "deleted_classes": ["ExplorerExportRateLimit"] + } + ], "browser": { "binding": "BROWSER" }, "env": { "testnet": { From 1fb992c1ddfa0cb4387ea9dbb38aef60b1349c82 Mon Sep 17 00:00:00 2001 From: o-az Date: Mon, 27 Apr 2026 19:17:28 -0700 Subject: [PATCH 3/4] fix: keep explorer durable object export for previews --- apps/explorer/src/index.server.ts | 2 ++ apps/explorer/src/lib/server/export-rate-limit.ts | 4 +++- apps/explorer/wrangler.json | 4 ---- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/explorer/src/index.server.ts b/apps/explorer/src/index.server.ts index 182182f3a..428708631 100644 --- a/apps/explorer/src/index.server.ts +++ b/apps/explorer/src/index.server.ts @@ -1,6 +1,8 @@ import * as Sentry from '@sentry/cloudflare' import handler, { createServerEntry } from '@tanstack/react-start/server-entry' +export { ExplorerExportRateLimit } from '#lib/server/export-rate-limit' + export const redirects: Array<{ from: RegExp to: (match: RegExpMatchArray) => string diff --git a/apps/explorer/src/lib/server/export-rate-limit.ts b/apps/explorer/src/lib/server/export-rate-limit.ts index b40e3334c..4c6268b55 100644 --- a/apps/explorer/src/lib/server/export-rate-limit.ts +++ b/apps/explorer/src/lib/server/export-rate-limit.ts @@ -1,4 +1,4 @@ -import { env } from 'cloudflare:workers' +import { DurableObject, env } from 'cloudflare:workers' import { getRequestHeader } from '@tanstack/react-start/server' const RATE_LIMIT_PERIOD_SECONDS = 60 @@ -18,6 +18,8 @@ export class RateLimitExceededError extends Error { } } +export class ExplorerExportRateLimit extends DurableObject {} + function getClientIp(): string | null { const cfConnectingIp = getRequestHeader('cf-connecting-ip')?.trim() if (cfConnectingIp) return cfConnectingIp diff --git a/apps/explorer/wrangler.json b/apps/explorer/wrangler.json index e57f3a36c..84d1c4140 100644 --- a/apps/explorer/wrangler.json +++ b/apps/explorer/wrangler.json @@ -27,10 +27,6 @@ { "tag": "v1", "new_sqlite_classes": ["ExplorerExportRateLimit"] - }, - { - "tag": "v2", - "deleted_classes": ["ExplorerExportRateLimit"] } ], "browser": { "binding": "BROWSER" }, From 7eb457c605002d170b8b388a1855fd4ababf5b84 Mon Sep 17 00:00:00 2001 From: o-az Date: Mon, 27 Apr 2026 19:24:53 -0700 Subject: [PATCH 4/4] fix: delete explorer export rate limit durable object --- apps/explorer/src/index.server.ts | 2 -- apps/explorer/src/lib/server/export-rate-limit.ts | 4 +--- apps/explorer/wrangler.json | 4 ++++ 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/explorer/src/index.server.ts b/apps/explorer/src/index.server.ts index 428708631..182182f3a 100644 --- a/apps/explorer/src/index.server.ts +++ b/apps/explorer/src/index.server.ts @@ -1,8 +1,6 @@ import * as Sentry from '@sentry/cloudflare' import handler, { createServerEntry } from '@tanstack/react-start/server-entry' -export { ExplorerExportRateLimit } from '#lib/server/export-rate-limit' - export const redirects: Array<{ from: RegExp to: (match: RegExpMatchArray) => string diff --git a/apps/explorer/src/lib/server/export-rate-limit.ts b/apps/explorer/src/lib/server/export-rate-limit.ts index 4c6268b55..b40e3334c 100644 --- a/apps/explorer/src/lib/server/export-rate-limit.ts +++ b/apps/explorer/src/lib/server/export-rate-limit.ts @@ -1,4 +1,4 @@ -import { DurableObject, env } from 'cloudflare:workers' +import { env } from 'cloudflare:workers' import { getRequestHeader } from '@tanstack/react-start/server' const RATE_LIMIT_PERIOD_SECONDS = 60 @@ -18,8 +18,6 @@ export class RateLimitExceededError extends Error { } } -export class ExplorerExportRateLimit extends DurableObject {} - function getClientIp(): string | null { const cfConnectingIp = getRequestHeader('cf-connecting-ip')?.trim() if (cfConnectingIp) return cfConnectingIp diff --git a/apps/explorer/wrangler.json b/apps/explorer/wrangler.json index 84d1c4140..e57f3a36c 100644 --- a/apps/explorer/wrangler.json +++ b/apps/explorer/wrangler.json @@ -27,6 +27,10 @@ { "tag": "v1", "new_sqlite_classes": ["ExplorerExportRateLimit"] + }, + { + "tag": "v2", + "deleted_classes": ["ExplorerExportRateLimit"] } ], "browser": { "binding": "BROWSER" },