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 @@ -448,7 +448,7 @@ All backend services share a single `.env` file (root or per-package). Copy `.en
| `API_AUTH_KEY` | — | API key for protected endpoints |
| `CORS_ORIGINS` | `http://localhost:3000` | Comma-separated allowed origins (required in production) |
| `WS_AUTH_SECRET` | — | HMAC secret for WebSocket token auth |
| `WS_AUTH_REQUIRED` | `false` | Require WS auth tokens |
| `WS_AUTH_REQUIRED` | *(prod: `true`, else `false`)* | Require WS auth tokens. Default follows `NODE_ENV`; set explicitly to override. |
| `MAX_WS_CONNECTIONS` | `1000` | Global WebSocket connection limit |

### Keeper Service
Expand Down
11 changes: 9 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ WebSocket connections support optional authentication:

### Configuration

- `WS_AUTH_REQUIRED=true` — Require authentication (default: false)
- `WS_AUTH_REQUIRED` — Require authentication. **The default is environment-dependent, not `false`:**
required when `NODE_ENV=production`, optional otherwise. Setting the variable to
`true` or `false` overrides that in either direction.
(Implemented in `percolator-api/src/routes/ws.ts:52-56`; this repo only documents it.)
Startup fails closed: production without `WS_AUTH_SECRET` exits, and so does
`WS_AUTH_REQUIRED=true` without a secret.
- `WS_AUTH_SECRET` — Secret key for HMAC tokens (change in production!)

### Authentication Methods
Expand Down Expand Up @@ -164,7 +169,9 @@ Rate limit violations are:
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
```

2. **Keep auth disabled** for easier testing
2. **Auth is already off in development** — the default outside
`NODE_ENV=production` is optional, so no setting is needed. Set it explicitly
only to override:
```bash
WS_AUTH_REQUIRED=false
```
Expand Down
38 changes: 38 additions & 0 deletions app/__tests__/api/issue-2520-mint-existence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";

// #2520 — every pre-existing check on this route was SHAPE-only (valid pubkey,
// printable name, ticker charset, decimals range). A syntactically valid pubkey
// for an account that was never created still landed in `devnet_mints`.
describe("#2520 devnet-register-mint verifies the mint on-chain", () => {
const src = fs.readFileSync(
path.join(__dirname, "../../app/api/devnet-register-mint/route.ts"),
"utf8",
);

it("fetches the account before upserting", () => {
expect(src).toMatch(/getAccountInfo\(new PublicKey\(mintAddress\)\)/);
});

it("rejects a nonexistent account", () => {
expect(src).toMatch(/does not exist on devnet/);
});

it("requires the token program as owner AND the exact SPL mint length", () => {
expect(src).toMatch(/owner\.equals\(TOKEN_PROGRAM_ID\)/);
expect(src).toMatch(/SPL_MINT_LEN\s*=\s*82/);
expect(src).toMatch(/data\.length\s*!==\s*SPL_MINT_LEN/);
});

it("fails CLOSED on an RPC error rather than falling through to the upsert", () => {
// an advisory check that skips on error would leave the issue half-closed
const catchBlock = src.slice(src.indexOf("mint existence check failed"));
expect(catchBlock).toMatch(/status:\s*503/);
expect(catchBlock).toMatch(/return NextResponse\.json/);
});

it("runs BEFORE the DB upsert", () => {
expect(src.indexOf("getAccountInfo")).toBeLessThan(src.indexOf('from("devnet_mints")'));
});
});
28 changes: 28 additions & 0 deletions app/__tests__/components/FooterSocialA11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";

// #2243 — the footer social icons carried `title` only. `title` is not a reliable
// accessible name (screen readers vary, and it is invisible on keyboard focus), so
// each link needs an explicit aria-label and each decorative glyph aria-hidden.
describe("#2243 footer social links have accessible names", () => {
const src = fs.readFileSync(
path.join(__dirname, "../../components/layout/Footer.tsx"),
"utf8",
);

const socials = ["GitHub", "X (Twitter)", "Discord", "Telegram"];

it.each(socials)("labels the %s link", (name) => {
expect(src).toContain(`aria-label="Percolator on ${name}"`);
});

it("hides every decorative social glyph from the a11y tree", () => {
// Count the social anchors by their aria-labels, then require at least as many
// aria-hidden svgs — so adding a link without hiding its glyph fails here.
const labelled = (src.match(/aria-label="Percolator on /g) ?? []).length;
const hidden = (src.match(/<svg aria-hidden="true"/g) ?? []).length;
expect(labelled).toBe(socials.length);
expect(hidden).toBeGreaterThanOrEqual(labelled);
});
});
31 changes: 31 additions & 0 deletions app/__tests__/hooks/issue-2412-liq-severity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { getLiquidationSeverity } from "../../hooks/usePortfolio";

// #2412 — both comparisons are FALSE for NaN, so an unguarded non-finite
// distance fell through to "safe": a position at liquidation risk rendering as
// fine. That is the one direction a risk indicator must never fail in.
describe("#2412 getLiquidationSeverity never reports safe on bad data", () => {
it("does not report NaN as safe", () => {
expect(getLiquidationSeverity(NaN)).not.toBe("safe");
});

it("does not report Infinity as safe", () => {
expect(getLiquidationSeverity(Infinity)).not.toBe("safe");
expect(getLiquidationSeverity(-Infinity)).not.toBe("safe");
});

it("fails toward danger, not merely warning", () => {
// A suppressed warning is a liquidation the user never saw coming; a
// spurious one is noise. The asymmetry justifies the loudest bucket.
expect(getLiquidationSeverity(NaN)).toBe("danger");
});

it("still classifies real values correctly — the guard did not flatten it", () => {
expect(getLiquidationSeverity(5)).toBe("danger");
expect(getLiquidationSeverity(10)).toBe("danger");
expect(getLiquidationSeverity(20)).toBe("warning");
expect(getLiquidationSeverity(30)).toBe("warning");
expect(getLiquidationSeverity(31)).toBe("safe");
expect(getLiquidationSeverity(100)).toBe("safe");
});
});
44 changes: 44 additions & 0 deletions app/__tests__/lib/indexer-db-network-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";

// #2513 — every read against the indexer DB must be scoped to the deployment's
// network. The `trades` and `funding_history` tables both carry a `network`
// column, but no query used it as a predicate, so a devnet deployment could
// serve mainnet rows and vice versa.
//
// Asserted structurally rather than by running SQL: the failure mode is a NEW
// query being added without the filter, and only a source-level invariant
// catches that. A value test over the existing queries would pass forever while
// query #13 silently leaks.
describe("#2513 indexer-db reads are network-scoped", () => {
const src = fs.readFileSync(
path.join(__dirname, "../../lib/indexer-db.ts"),
"utf8",
);
const lines = src.split("\n");

it("every FROM trades / funding_history is followed by a network predicate", () => {
const offenders: string[] = [];
lines.forEach((line, i) => {
if (!/FROM\s+(trades|funding_history)\b/.test(line)) return;
// the predicate may be on this line (single-line form) or within the
// next few lines of the same statement
const window = [line, ...lines.slice(i + 1, i + 5)].join("\n");
if (!/network\s*=\s*\$\{getServerNetwork\(\)\}/.test(window)) {
offenders.push(`line ${i + 1}: ${line.trim()}`);
}
});
expect(offenders).toEqual([]);
});

it("actually found query sites — the scan cannot pass vacuously", () => {
const sites = lines.filter((l) => /FROM\s+(trades|funding_history)\b/.test(l));
expect(sites.length).toBeGreaterThanOrEqual(12);
});

it("imports the server-side resolver, not the localStorage one", () => {
expect(src).toMatch(/import \{ getServerNetwork \} from "\.\/supabase"/);
expect(src).not.toMatch(/from "\.\/config"/);
});
});
32 changes: 32 additions & 0 deletions app/__tests__/lib/issue-2321-volume-finite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";

// #2321 — hasVolumeData gated the volume pane on `(c.volume ?? 0) > 0`.
// NaN > 0 is false so NaN was already excluded, but Infinity > 0 is TRUE, so a
// single corrupt candle enabled the pane and handed Infinity to the histogram
// series, which then scales the whole pane off that value.
//
// Mirrors the predicate rather than importing the component (TradingChart pulls
// in lightweight-charts + a canvas). The assertion that matters is the shape of
// the guard: finiteness first, then positivity.
const hasVolumeData = (candles: { volume?: number }[]) =>
candles.some((c) => Number.isFinite(c.volume) && (c.volume ?? 0) > 0);

describe("#2321 volume pane requires FINITE volume", () => {
it("rejects Infinity — the case `> 0` alone let through", () => {
expect(hasVolumeData([{ volume: Infinity }])).toBe(false);
expect(hasVolumeData([{ volume: -Infinity }])).toBe(false);
});

it("rejects NaN and missing volume", () => {
expect(hasVolumeData([{ volume: NaN }])).toBe(false);
expect(hasVolumeData([{}])).toBe(false);
});

it("still accepts a real positive volume", () => {
expect(hasVolumeData([{ volume: 0 }, { volume: 1234 }])).toBe(true);
});

it("one corrupt candle does not enable the pane on its own", () => {
expect(hasVolumeData([{ volume: 0 }, { volume: Infinity }])).toBe(false);
});
});
34 changes: 34 additions & 0 deletions app/__tests__/lib/issue-2523-payload-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";

// #2523 — the canonicaliser's last-resort throw said only "Unsupported market
// registration payload value". Because it is recursive, that one sentence was
// the entire signal for a bad value buried anywhere in the payload.
describe("#2523 payload canonicaliser error is diagnostic", () => {
const src = fs.readFileSync(
path.join(__dirname, "../../lib/market-registration-auth.ts"),
"utf8",
);

it("names the offending type", () => {
expect(src).toMatch(/type "\$\{typeof value\}"/);
});

it("names the likely causes so the message is actionable", () => {
expect(src).toMatch(/bigint and function values are the usual causes/);
});

it("does NOT interpolate the value itself — that would leak payload contents", () => {
// `${typeof value}` is fine; a bare `${value}` or JSON.stringify(value) in the
// throw would put user payload into a client-visible error and the logs.
const thrown = src.slice(src.indexOf("Unsupported market registration payload value"));
const firstThrowBlock = thrown.slice(0, 400);
expect(firstThrowBlock).not.toMatch(/\$\{value\}/);
expect(firstThrowBlock).not.toMatch(/JSON\.stringify\(value\)/);
});

it("keeps the sibling non-plain-object error intact", () => {
expect(src).toMatch(/must contain plain JSON objects/);
});
});
32 changes: 32 additions & 0 deletions app/__tests__/lib/priceStore-bounded.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest";

// #2320 — `entries` was only ever written, never pruned. WebSocket resources were
// already released on last-unsubscribe, so this was never a socket leak, but the
// deliberately-retained snapshots grew without bound over a long browsing session.
//
// Driven entirely through the public API: seedFromOnChain() creates an entry, and
// getSnapshot() reports whether one is still cached.
describe("#2320 priceStore caps retained snapshots", () => {
it("evicts idle entries past the cap, and keeps a subscribed one alive", async () => {
vi.resetModules();
const store = await import("../../lib/priceStore/priceStore");

// A subscribed slab must survive eviction no matter how much churn follows.
const pinned = "PINNED_SLAB";
const unsub = store.subscribeSlab(pinned, () => {});
store.seedFromOnChain(pinned, 1_000_000n);
expect(store.getSnapshot(pinned)).not.toBe(store.EMPTY_PRICE_STATE);

// Churn well past the 64 cap with idle slabs.
for (let i = 0; i < 300; i++) store.seedFromOnChain(`IDLE_${i}`, BigInt(i + 1));

// The pinned, subscribed entry is still cached...
expect(store.getSnapshot(pinned)).not.toBe(store.EMPTY_PRICE_STATE);
// ...while the earliest idle ones have been evicted (unbounded growth would
// have kept every one of them).
const earliestGone = store.getSnapshot("IDLE_0") === store.EMPTY_PRICE_STATE;
expect(earliestGone).toBe(true);

unsub();
});
});
39 changes: 38 additions & 1 deletion app/app/api/devnet-register-mint/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
*/

import { NextRequest, NextResponse } from "next/server";
import { PublicKey } from "@solana/web3.js";
import { Connection, PublicKey } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { getRpcEndpoint } from "@/lib/config";
import * as Sentry from "@sentry/nextjs";
import { getServiceClient } from "@/lib/supabase";
import { getClientIp } from "@/lib/get-client-ip";
Expand Down Expand Up @@ -106,6 +108,41 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Invalid decimals (must be integer 0-18)" }, { status: 400 });
}

// #2520: every check above is SHAPE-only — a syntactically valid pubkey for an
// account that was never created still landed in `devnet_mints`, so the registry
// could carry entries with no mint behind them. Confirm the account exists and
// is an SPL mint owned by the token program (same shape /api/devnet-airdrop uses
// before trusting a mirror row). SPL Mint is a fixed 82 bytes.
//
// This is EXISTENCE, not ownership: it does not prove the caller controls the
// mint. Ownership would need a signature and a client-contract change — tracked
// separately on the issue.
const SPL_MINT_LEN = 82;
try {
const conn = new Connection(getRpcEndpoint(), "confirmed");
const info = await conn.getAccountInfo(new PublicKey(mintAddress));
if (!info) {
return NextResponse.json(
{ error: "mintAddress does not exist on devnet" },
{ status: 400 },
);
}
if (!info.owner.equals(TOKEN_PROGRAM_ID) || info.data.length !== SPL_MINT_LEN) {
return NextResponse.json(
{ error: "mintAddress is not an SPL mint account" },
{ status: 400 },
);
}
} catch (rpcErr) {
// Fail CLOSED: an RPC failure must not fall through to the upsert, or the
// check becomes advisory and this issue is only half-closed.
console.warn("[devnet-register-mint] mint existence check failed:", rpcErr);
return NextResponse.json(
{ error: "Could not verify mintAddress on-chain — try again" },
{ status: 503 },
);
}

// Best-effort DB upsert — guarded when Supabase unavailable
try {
const supabase = getServiceClient();
Expand Down
32 changes: 30 additions & 2 deletions app/app/api/rpc/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,13 @@ function validateRequest(req: Record<string, unknown>): { jsonrpc: string; error
* @param networkOverride — optional "mainnet"|"devnet" to route to a specific Helius endpoint
* (used by Privy so both chains can be initialised without exposing any API key)
*/
/**
* #2522: upstream RPC timeout. 15s is above p99 for a healthy provider and well
* under the platform function limit, so a hung upstream surfaces as a clean
* error to the caller instead of consuming the whole invocation budget.
*/
const RPC_UPSTREAM_TIMEOUT_MS = 15_000;

async function processSingleRequest(
req: JsonRpcRequest,
networkOverride?: "mainnet" | "devnet",
Expand All @@ -333,15 +340,36 @@ async function processSingleRequest(

// Deduplicate in-flight requests for read-only methods
if (!isMutating && inflightRequests.has(cacheKey)) {
const result = await inflightRequests.get(cacheKey)!;
return { ...(result as Record<string, unknown>), id: req.id };
// #2522: this await is OUTSIDE the try/catch below, so a rejection here
// bypasses BUG 14's batch protection and fails the whole batch. That was
// rare while the upstream fetch had no timeout (it hung rather than
// rejected); adding AbortSignal.timeout makes rejection the NORMAL outcome
// of a slow upstream, so the dedup path needs the same handling.
try {
const result = await inflightRequests.get(cacheKey)!;
return { ...(result as Record<string, unknown>), id: req.id };
} catch (err) {
console.error(`[/api/rpc] deduped request failed for method ${method}:`, err);
return {
jsonrpc: "2.0",
error: { code: -32603, message: "Upstream RPC request failed" },
id: req.id,
};
}
}

const fetchPromise = (async () => {
// #2522: bound the upstream call. Without a signal this fetch inherits the
// platform default (effectively none), so one unresponsive RPC holds the
// route open until the function times out. That is worse than it looks here:
// read-only requests are DEDUPLICATED on `cacheKey`, so every later caller
// for the same method awaits the SAME hung promise (`:335-337`) rather than
// issuing its own — one slow upstream call stalls all of them together.
const response = await fetch(getRpcUrl(networkOverride), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
signal: AbortSignal.timeout(RPC_UPSTREAM_TIMEOUT_MS),
});
return await response.json();
})();
Expand Down
Loading
Loading