diff --git a/apps/faucet/package.json b/apps/faucet/package.json index 095f045722..19662a5811 100644 --- a/apps/faucet/package.json +++ b/apps/faucet/package.json @@ -14,13 +14,13 @@ "@happy.tech/txm": "workspace:0.1.0", "@hono/node-server": "^1.13.8", "@scalar/hono-api-reference": "^0.5.175", + "better-sqlite3": "^11.7.0", "hono": "^4.7.2", "hono-openapi": "^0.4.4", - "neverthrow": "^8.2.0", - "zod": "^3.23.8", - "better-sqlite3": "^11.7.0", "kysely": "^0.27.5", - "viem": "^2.21.53" + "neverthrow": "^8.2.0", + "viem": "^2.21.53", + "zod": "^3.23.8" }, "devDependencies": { "@happy.tech/happybuild": "workspace:0.1.1", diff --git a/apps/faucet/src/env.ts b/apps/faucet/src/env.ts index 1871d80308..1971bfeb63 100644 --- a/apps/faucet/src/env.ts +++ b/apps/faucet/src/env.ts @@ -24,7 +24,6 @@ const envSchema = z.object({ TOKEN_AMOUNT: z.string().transform((s) => BigInt(s)), FAUCET_DB_PATH: z.string().trim(), FAUCET_RATE_LIMIT_WINDOW_SECONDS: z.string().transform((s) => Number(s)), - FAUCET_RATE_LIMIT_MAX_REQUESTS: z.string().transform((s) => Number(s)), }) const parsedEnv = envSchema.safeParse(process.env) diff --git a/apps/faucet/src/errors.ts b/apps/faucet/src/errors.ts index 9a0892f262..2a62cc270a 100644 --- a/apps/faucet/src/errors.ts +++ b/apps/faucet/src/errors.ts @@ -1,3 +1,4 @@ +import { formatMs } from "@happy.tech/common" import type { ContentfulStatusCode } from "hono/utils/http-status" export abstract class HappyFaucetError extends Error { @@ -23,8 +24,12 @@ export class FaucetFetchError extends HappyFaucetError { } export class FaucetRateLimitError extends HappyFaucetError { - constructor(message?: string, options?: ErrorOptions) { - super(429, message || "Rate limit exceeded", options) + constructor(timeToWait: number, message?: string, options?: ErrorOptions) { + super( + 429, + message || "Rate limit exceeded, please wait " + formatMs(timeToWait, true) + " before requesting again", + options, + ) } } diff --git a/apps/faucet/src/faucet-usage.repository.ts b/apps/faucet/src/faucet-usage.repository.ts index 20a09ae6e0..e5ca2fed94 100644 --- a/apps/faucet/src/faucet-usage.repository.ts +++ b/apps/faucet/src/faucet-usage.repository.ts @@ -28,7 +28,12 @@ export class FaucetUsageRepository { async findAllByAddress(address: Address): Promise> { const result = await ResultAsync.fromPromise( - db.selectFrom("faucetUsage").selectAll().where("address", "=", address).execute(), + db + .selectFrom("faucetUsage") + .selectAll() + .where("address", "=", address) + .orderBy("occurredAt", "desc") + .execute(), unknownToError, ) diff --git a/apps/faucet/src/services/faucet.ts b/apps/faucet/src/services/faucet.ts index b445056406..160ee2fede 100644 --- a/apps/faucet/src/services/faucet.ts +++ b/apps/faucet/src/services/faucet.ts @@ -29,8 +29,14 @@ export class FaucetService { if (faucetUsageResult.isErr()) { return err(faucetUsageResult.error) } - if (faucetUsageResult.value.length >= env.FAUCET_RATE_LIMIT_MAX_REQUESTS) { - return err(new FaucetRateLimitError()) + if (faucetUsageResult.value.length >= 1) { + const lastRequest = faucetUsageResult.value[0] + const timeToWait = + lastRequest.occurredAt.getTime() + env.FAUCET_RATE_LIMIT_WINDOW_SECONDS * 1000 - Date.now() + + if (timeToWait > 0) { + return err(new FaucetRateLimitError(timeToWait)) + } } const faucetUsage = FaucetUsage.create(address) diff --git a/support/common/lib/index.ts b/support/common/lib/index.ts index b180ba948e..576efd2b60 100644 --- a/support/common/lib/index.ts +++ b/support/common/lib/index.ts @@ -93,6 +93,7 @@ export { stringify } from "./utils/string" export { throttle } from "./utils/throttle" export { getUrlProtocol } from "./utils/urlProtocol" export { type UUID, createUUID } from "./utils/uuid" +export { formatMs } from "./utils/time" // === DATA ======================================================================================== diff --git a/support/common/lib/utils/time.ts b/support/common/lib/utils/time.ts new file mode 100644 index 0000000000..65c42e347c --- /dev/null +++ b/support/common/lib/utils/time.ts @@ -0,0 +1,18 @@ +const unit = { + ms: 1, + s: 1e3, + m: 60e3, + h: 3_600e3, + d: 86_400e3, + } as const; + +export function formatMs(ms: number, long = false): string { + const abs = Math.abs(ms); + for (const [abbr, size] of Object.entries(unit).reverse()) { + if (abs >= size) { + const val = Math.round(ms / size); + return long ? `${val} ${abbr}${Math.abs(val) !== 1 ? 's' : ''}` : `${val}${abbr}`; + } + } + return `${ms}ms`; +} \ No newline at end of file