Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
79 changes: 79 additions & 0 deletions docs/dev-stack.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 21 additions & 2 deletions src/app/prices/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";
import {
Alert,
AlertIcon,
Expand All @@ -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],
Expand All @@ -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() });
};

Expand Down Expand Up @@ -107,7 +119,14 @@ export default function PricesPage() {
_focus={{ boxShadow: "none", borderColor: "app.accent" }}
/>
</Box>
<Button type="submit" bg="app.accent" color="app.onAccent" _hover={{ opacity: 0.9 }} isLoading={isFetching}>
<Button
type="submit"
bg="app.accent"
color="app.onAccent"
_hover={{ opacity: 0.9 }}
isLoading={isFetching}
isDisabled={coolingDown}
>
Look up
</Button>
</Flex>
Expand Down
8 changes: 7 additions & 1 deletion src/lib/backend.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { backendApiUrl } from "@/config";
import { readJsonWithLimit, ResponseTooLargeError } from "./safe-fetch";

export class BackendApiError extends Error {
status: number;
Expand Down Expand Up @@ -55,7 +56,12 @@ async function request<T>(
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<any>(res).catch((err) => {
if (err instanceof ResponseTooLargeError) throw err;
return null;
});

if (!res.ok) {
const message =
Expand Down
24 changes: 24 additions & 0 deletions src/lib/safe-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
62 changes: 62 additions & 0 deletions src/lib/safe-fetch.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown>(
response: Response,
maxBytes: number = MAX_RESPONSE_BYTES,
): Promise<T> {
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;
}
17 changes: 13 additions & 4 deletions src/lib/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>;
private factoryWarningLogged = false;

constructor() {
this.rpcServer = rpcServer;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -1019,7 +1025,10 @@ export class SorobanService {
*/
async getFactoryPools(): Promise<PoolInfo[]> {
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 [];
}

Expand Down Expand Up @@ -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<Partial<LeaderboardRow>>;
total?: number;
};
}>(res);
const entries: LeaderboardRow[] = (data.entries ?? []).map((e) => ({
address: String(e.address ?? ''),
totalCredits: Number(e.totalCredits ?? 0),
Expand Down
3 changes: 2 additions & 1 deletion src/lib/stellar.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { horizonUrl } from '@/config';
import { readJsonWithLimit } from './safe-fetch';

export interface AccountBalance {
asset_type: string;
Expand Down Expand Up @@ -36,7 +37,7 @@ export async function fetchHorizonAccount(
);
}

return (await response.json()) as HorizonAccount;
return readJsonWithLimit<HorizonAccount>(response);
}

/**
Expand Down