diff --git a/README.md b/README.md index 079d292..9bd5662 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ npm run dev # frontend only — on-chain pages work, backend pages show npm run dev:stack # frontend + smartdrop-backend + an in-memory Redis, all in one command ``` -`dev:stack` (`scripts/dev-stack.sh`) expects [`smartdrop-backend`](https://github.com/Mainnet-ops/smartdrop-backend) cloned as a sibling directory (`../smartdrop-backend`), or point it elsewhere with `SMARTDROP_BACKEND_DIR=/path npm run dev:stack`. It needs no Docker or system Redis install — the first run installs a small in-memory Redis under `~/.smartdrop-dev/` and reuses it on every subsequent run, along with a persisted admin API key (printed on startup, needed for `/alerts`). Ctrl+C stops all three processes; logs land in `~/.smartdrop-dev/logs/`. +`dev:stack` (`scripts/dev-stack.sh`) expects [`smartdrop-backend`](https://github.com/Mainnet-ops/smartdrop-backend) cloned as a sibling directory (`../smartdrop-backend`), or point it elsewhere with `SMARTDROP_BACKEND_DIR=/path npm run dev:stack`. It needs no Docker or system Redis install — the first run installs a small in-memory Redis under `~/.smartdrop-dev/` and reuses it on every subsequent run, along with a persisted admin API key (printed on startup, needed for `/alerts`). Ctrl+C stops all three processes; logs land in `~/.smartdrop-dev/logs/`. See [`docs/dev-stack.md`](docs/dev-stack.md) for what it does, when to use it, and troubleshooting. Open [http://localhost:3000](http://localhost:3000). Production: `npm run build` / `npm start`. diff --git a/docs/dev-stack.md b/docs/dev-stack.md new file mode 100644 index 0000000..bab99af --- /dev/null +++ b/docs/dev-stack.md @@ -0,0 +1,79 @@ +# Full-stack dev setup (`scripts/dev-stack.sh`) + +`scripts/dev-stack.sh` starts everything the app needs locally, in one command: + +1. An in-memory Redis on port `6379` +2. [`smartdrop-backend`](https://github.com/Mainnet-ops/smartdrop-backend) on port `4000` +3. This frontend on port `3000` + +## When to use it + +Run it when you work on pages that talk to the backend: `/prices`, `/airdrops`, +`/webhooks` and `/alerts`. For pages that only use Stellar RPC and Horizon (the +on-chain pages), `npm run dev` is enough and starts faster. + +## Prerequisites + +- Node.js 20+ and npm, with dependencies installed in this repo (`npm ci`) +- `smartdrop-backend` cloned as a sibling directory (`../smartdrop-backend`), with + its dependencies installed +- `openssl` and `lsof` available on your `PATH` + +No Docker or system Redis install is needed. + +## Usage + +```bash +npm run dev:stack +# or +./scripts/dev-stack.sh +``` + +If the backend lives somewhere else: + +```bash +SMARTDROP_BACKEND_DIR=/path/to/smartdrop-backend npm run dev:stack +``` + +Point the frontend at the local backend in `.env.local`: + +``` +NEXT_PUBLIC_BACKEND_API_URL=http://localhost:4000/api/v1 +``` + +Press `Ctrl+C` to stop all three processes. + +## What it does on each run + +- **Admin API key.** Generated once with `openssl rand -hex 32`, saved to + `~/.smartdrop-dev/admin-api-key.txt`, and reused on later runs. It is printed + on startup; paste it into the `/alerts` page, which requires it. +- **In-memory Redis.** On the first run it installs `redis-memory-server` under + `~/.smartdrop-dev/redis-mem/` and reuses it afterwards. If something is already + listening on `6379`, that Redis is reused instead. +- **Backend.** Starts `npm run dev` in the backend directory with + `PORT=4000`, `REDIS_URL=redis://localhost:6379`, `NODE_ENV=development`, the + admin key, and `CORS_ALLOWED_ORIGINS` allowing `http://localhost:3000` and + `http://localhost:3001`. If something is already listening on `4000`, it is + reused. +- **Frontend.** Runs `npm run dev` in the foreground. + +## Files and logs + +Everything the script creates lives under `~/.smartdrop-dev/`: + +| Path | Contents | +|---|---| +| `admin-api-key.txt` | The persisted admin API key | +| `redis-mem/` | The in-memory Redis helper | +| `logs/redis.log` | Redis output | +| `logs/backend.log` | Backend output | + +## Troubleshooting + +- **`couldn't find smartdrop-backend`**: clone it next to this repo or set + `SMARTDROP_BACKEND_DIR`. +- **`Redis didn't start in time` / `backend didn't start in time`**: read the + matching log in `~/.smartdrop-dev/logs/`. +- **Port already in use by something unrelated**: the script reuses whatever is + listening on `6379` and `4000`, so stop the other process first. diff --git a/src/app/prices/page.tsx b/src/app/prices/page.tsx index 713cbfd..001583f 100644 --- a/src/app/prices/page.tsx +++ b/src/app/prices/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Alert, AlertIcon, @@ -27,10 +27,20 @@ function formatUsd(value: number | null) { }); } +/** Minimum time between lookups, so the Look up button cannot be spammed (#486). */ +const LOOKUP_COOLDOWN_MS = 2000; + export default function PricesPage() { const [assetCode, setAssetCode] = useState("XLM"); const [issuer, setIssuer] = useState(""); const [submitted, setSubmitted] = useState({ assetCode: "XLM", issuer: "" }); + const [coolingDown, setCoolingDown] = useState(false); + + useEffect(() => { + if (!coolingDown) return; + const timer = setTimeout(() => setCoolingDown(false), LOOKUP_COOLDOWN_MS); + return () => clearTimeout(timer); + }, [coolingDown]); const { data, isLoading, isError, error, isFetching, refetch } = useQuery({ queryKey: ["price", submitted.assetCode, submitted.issuer], @@ -41,6 +51,8 @@ export default function PricesPage() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + if (coolingDown || isFetching) return; + setCoolingDown(true); setSubmitted({ assetCode: assetCode.trim().toUpperCase(), issuer: issuer.trim() }); }; @@ -107,7 +119,14 @@ export default function PricesPage() { _focus={{ boxShadow: "none", borderColor: "app.accent" }} /> - diff --git a/src/lib/backend.ts b/src/lib/backend.ts index 4ee84db..8f4ad98 100644 --- a/src/lib/backend.ts +++ b/src/lib/backend.ts @@ -1,4 +1,5 @@ import { backendApiUrl } from "@/config"; +import { readJsonWithLimit, ResponseTooLargeError } from "./safe-fetch"; export class BackendApiError extends Error { status: number; @@ -55,7 +56,12 @@ async function request( if (apiKey) headers.set("Authorization", `Bearer ${apiKey}`); const res = await fetch(`${backendApiUrl}${path}`, { ...rest, headers }); - const body = await res.json().catch(() => null); + // An oversized response is an error, not an empty body (#488). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const body = await readJsonWithLimit(res).catch((err) => { + if (err instanceof ResponseTooLargeError) throw err; + return null; + }); if (!res.ok) { const message = diff --git a/src/lib/safe-fetch.test.ts b/src/lib/safe-fetch.test.ts new file mode 100644 index 0000000..de5b519 --- /dev/null +++ b/src/lib/safe-fetch.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { readJsonWithLimit, ResponseTooLargeError } from "./safe-fetch"; + +describe("readJsonWithLimit", () => { + it("parses a response within the limit", async () => { + const response = new Response(JSON.stringify({ ok: true })); + await expect(readJsonWithLimit(response, 1024)).resolves.toEqual({ ok: true }); + }); + + it("rejects when the declared Content-Length exceeds the limit", async () => { + const response = new Response("{}", { headers: { "content-length": "2048" } }); + await expect(readJsonWithLimit(response, 1024)).rejects.toBeInstanceOf(ResponseTooLargeError); + }); + + it("rejects when the streamed body exceeds the limit without a declared length", async () => { + const response = new Response(JSON.stringify({ data: "x".repeat(2048) })); + await expect(readJsonWithLimit(response, 1024)).rejects.toBeInstanceOf(ResponseTooLargeError); + }); + + it("falls back to json() when the response has no readable body", async () => { + const response = { json: async () => ({ ok: true }) } as unknown as Response; + await expect(readJsonWithLimit(response, 1024)).resolves.toEqual({ ok: true }); + }); +}); diff --git a/src/lib/safe-fetch.ts b/src/lib/safe-fetch.ts new file mode 100644 index 0000000..f94e9c9 --- /dev/null +++ b/src/lib/safe-fetch.ts @@ -0,0 +1,62 @@ +/** + * Bounded response reading (#488). + * + * Responses from Horizon, the leaderboard API and the backend are parsed as + * JSON in memory. A misbehaving or malicious proxy could return an enormous + * body, so the declared `Content-Length` is checked before reading, and the + * body is also counted as it streams in (a proxy can omit or understate the + * header) so reading stops once the limit is passed. + */ + +/** 5 MiB — far above any legitimate JSON payload this app requests. */ +export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; + +export class ResponseTooLargeError extends Error { + constructor(maxBytes: number, declaredBytes?: number) { + super( + declaredBytes !== undefined + ? `Response is too large (${declaredBytes} bytes declared, limit ${maxBytes}).` + : `Response is too large (exceeded the ${maxBytes}-byte limit).`, + ); + this.name = "ResponseTooLargeError"; + } +} + +export async function readJsonWithLimit( + response: Response, + maxBytes: number = MAX_RESPONSE_BYTES, +): Promise { + const declared = Number(response.headers?.get?.("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) { + void response.body?.cancel?.().catch(() => undefined); + throw new ResponseTooLargeError(maxBytes, declared); + } + + const reader = response.body?.getReader?.(); + if (!reader) { + // No readable stream (for example a minimal fetch stand-in): the declared + // length check above is all that can be applied. + return (await response.json()) as T; + } + + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new ResponseTooLargeError(maxBytes); + } + chunks.push(value); + } + + const merged = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(merged)) as T; +} diff --git a/src/lib/soroban.ts b/src/lib/soroban.ts index 76fcf1e..b152d26 100644 --- a/src/lib/soroban.ts +++ b/src/lib/soroban.ts @@ -26,6 +26,7 @@ import { } from '@/config'; import { ConfigError, FeeBumpError, FreighterError, SecurityError } from './error-handler'; import { fetchAccountBalances, fetchHorizonAccount } from './stellar'; +import { readJsonWithLimit } from './safe-fetch'; import { bigintToDisplayAmount, parsePoolsFromNative, @@ -883,6 +884,7 @@ export class SorobanService { private static ACCOUNT_CACHE_TTL_MS = 3_000; // Memoized lazy-init promise (issue #451) — see ensureInitialized(). private initPromise?: Promise; + private factoryWarningLogged = false; constructor() { this.rpcServer = rpcServer; @@ -976,7 +978,11 @@ export class SorobanService { if (factoryAddress) { this.factoryContract = new Contract(factoryAddress); } - + + // Without a factory there are no pools to load; skip the call instead of + // logging a warning on every page load (#487). + if (!this.factoryContract) return; + // Load existing pools await this.loadPoolContracts(); } @@ -1019,7 +1025,10 @@ export class SorobanService { */ async getFactoryPools(): Promise { if (!this.factoryContract) { - console.warn('Factory contract not initialized; returning empty pool list'); + if (!this.factoryWarningLogged) { + this.factoryWarningLogged = true; + console.warn('Factory contract not initialized; returning empty pool list'); + } return []; } @@ -1832,10 +1841,10 @@ export class SorobanService { const res = await fetch(url.toString(), { headers: { accept: 'application/json' } }); if (!res.ok) throw new Error(`Leaderboard API responded ${res.status}`); - const data = (await res.json()) as { + const data = await readJsonWithLimit<{ entries?: Array>; total?: number; - }; + }>(res); const entries: LeaderboardRow[] = (data.entries ?? []).map((e) => ({ address: String(e.address ?? ''), totalCredits: Number(e.totalCredits ?? 0), diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index d663865..e78055c 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -1,4 +1,5 @@ import { horizonUrl } from '@/config'; +import { readJsonWithLimit } from './safe-fetch'; export interface AccountBalance { asset_type: string; @@ -36,7 +37,7 @@ export async function fetchHorizonAccount( ); } - return (await response.json()) as HorizonAccount; + return readJsonWithLimit(response); } /**