Skip to content

Validate market symbol/name on the live registration path - #2467

Open
0x-SquidSol wants to merge 1 commit into
dcccrypto:playgroundfrom
0x-SquidSol:fix/validate-market-metadata-on-registration
Open

Validate market symbol/name on the live registration path#2467
0x-SquidSol wants to merge 1 commit into
dcccrypto:playgroundfrom
0x-SquidSol:fix/validate-market-metadata-on-registration

Conversation

@0x-SquidSol

@0x-SquidSol 0x-SquidSol commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

Validate market symbol/name on the live registration path
(/api/playground/keeper-register), closing a metadata-impersonation gap where a
market creator could store deceptive names/tickers (homoglyph / RTL-override /
zero-width names, control characters, or overlong values).

Closes #2466.

Why

The launch flow registers markets via keeper-register, which wrote the markets
row and the Blob registry after only a non-empty-string check. The symbol/name
validation lived solely on the now-unused POST /api/markets path, so it was dead
code on the live path. Both the top-level symbol/label body fields and
payload.symbol/payload.name are caller-controlled and reach both stores.

Changes

  • lib/market-metadata-validation (new) — single source of truth:
    checkSymbol/checkName plus validateSymbol/validateName
    resolve-then-validate wrappers. Rules are unchanged from the original create
    path: symbol 1–20 [A-Za-z0-9._-]; name 1–64, no control chars, no
    invisible/bidirectional formatting chars (visible Unicode — accents, CJK, emoji
    — still allowed).
  • keeper-register — validate symbol/label (body) and
    payload.symbol/payload.name before any write; return HTTP 400 on invalid
    input. Server-derived fallbacks ("UNKNOWN", the derived label,
    Market <slab>) are safe and unvalidated; the retry path (which sends nulls) is
    unaffected.
  • POST /api/markets — refactored to consume the shared validator
    (behavior- and message-preserving) so the two paths cannot drift again.
  • Regression test — asserts the shared validator rejects the impersonation
    corpus the previous non-empty gate accepted, and accepts legitimate metadata.

Testing

  • npx tsc --noEmit — clean.
  • New test + existing gh1963-markets-input-validation + text-safety — 45/45 pass.
  • Market/registration cluster (markets-deployer-auth, markets-post-leverage-guard,
    useCreateMarket-keeper-register, keeper-register-concurrency-race,
    market-registration, mobile-create-market-blockhash-recovery) — 48/48 pass.
  • Full suite otherwise green; the single unrelated failure
    (blocklist-indexer-sync, a pre-existing cross-repo blocklist drift) is
    untouched by this change.

Notes

  • Frontend + devnet scope only; no program, keeper, or mainnet changes.
  • App output is already React-escaped, so this addresses impersonation, not
    script injection.

Summary by CodeRabbit

Release Notes

  • Tests

    • Added regression tests for market metadata validation across registration endpoints, verifying rejection of impersonation attempts, unsafe characters, whitespace-only values, and excessive lengths.
  • New Features

    • Implemented unified validation for market symbols and names across all registration endpoints to enforce consistent security standards and data integrity.

The market-creation flow registers markets via
/api/playground/keeper-register, which wrote the market row (and the Blob
registry) after only a non-empty-string check on caller-supplied
symbol/name/label. The symbol/name validation — charset, length,
control-character and invisible/bidirectional-character rejection — lived only
on the POST /api/markets path, which the launch flow no longer calls. As a
result a market creator could register deceptive metadata (homoglyph /
RTL-override / zero-width names, control characters, or overlong values) and
impersonate an existing market across every surface that renders these fields.

Extract the validation into lib/market-metadata-validation as the single source
of truth and apply it on keeper-register before any write. Refactor
POST /api/markets to consume the same module so the two paths cannot drift.

- add lib/market-metadata-validation (checkSymbol/checkName + validate* wrappers)
- keeper-register: reject invalid symbol/name/label (top-level body and payload)
  with HTTP 400 before the DB row or Blob registry write; server-derived
  fallbacks are unaffected and the retry path (which sends nulls) is unchanged
- markets/route: consume the shared validator (behavior- and message-preserving)
- add a regression test asserting the shared validator rejects the impersonation
  corpus the previous non-empty gate accepted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@0x-SquidSol
0x-SquidSol requested a review from dcccrypto as a code owner August 4, 2026 16:44
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

@0x-SquidSol is attempting to deploy a commit to the Khubair Nasir's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds shared market symbol and name validation, applies it to keeper registration and market registration routes, and adds regression tests for impersonation, unsafe characters, whitespace-only values, and excessive length.

Changes

Market metadata validation

Layer / File(s) Summary
Shared metadata validation contract
app/lib/market-metadata-validation.ts, app/__tests__/api/keeper-register-metadata-validation.test.ts
Added shared symbol and name checks, fallback resolution, and Vitest coverage for invalid and valid metadata.
Keeper registration enforcement
app/app/api/playground/keeper-register/route.ts
Validates caller and payload metadata before database or Blob writes and returns HTTP 400 for invalid values.
Markets route validation reuse
app/app/api/markets/route.ts
Replaced inline metadata checks with the shared validators and uses validated values for registration.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: dcccrypto

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant KeeperRegister
  participant MetadataValidation
  participant Database
  participant BlobRegistry
  Client->>KeeperRegister: submit market metadata
  KeeperRegister->>MetadataValidation: validateSymbol and validateName
  MetadataValidation-->>KeeperRegister: validated values or error
  KeeperRegister->>Database: persist validated metadata
  KeeperRegister->>BlobRegistry: persist validated metadata
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes validation of market symbol and name values on the live registration path.
Linked Issues check ✅ Passed The changes satisfy issue #2466 by validating all caller-controlled metadata before storage and reusing shared validation in both registration routes.
Out of Scope Changes check ✅ Passed All changes are related to metadata validation, regression coverage, and shared validation reuse required by issue #2466.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/__tests__/api/keeper-register-metadata-validation.test.ts (1)

21-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Exercise the keeper registration route.

This test only calls the shared helpers. It does not prove that /api/playground/keeper-register returns HTTP 400 before upsertRegisteredMarketRow and upsertRegisteredMarket.

Mock the persistence boundaries. Invoke POST with invalid body and payload metadata. Assert HTTP 400 and zero write calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/__tests__/api/keeper-register-metadata-validation.test.ts` around lines
21 - 22, Replace the helper-only coverage in the keeper registration test with
an exercise of the route’s POST handler: mock upsertRegisteredMarketRow and
upsertRegisteredMarket, submit invalid body and payload metadata to
/api/playground/keeper-register, assert an HTTP 400 response, and verify both
persistence functions receive zero calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/lib/market-metadata-validation.ts`:
- Around line 61-72: Update the resolution logic in both validateSymbol and
validateName functions to explicitly reject non-null non-string raw values
before defaulting to the fallback. Instead of treating all non-string inputs
(like objects or numbers) as absent and using the fallback, check if raw is null
or undefined to use the fallback, check if raw is a string with length greater
than 0 to use raw, and return an error response for any non-null non-string
values to prevent invalid types from being processed as successful with a
fallback value.

---

Nitpick comments:
In `@app/__tests__/api/keeper-register-metadata-validation.test.ts`:
- Around line 21-22: Replace the helper-only coverage in the keeper registration
test with an exercise of the route’s POST handler: mock
upsertRegisteredMarketRow and upsertRegisteredMarket, submit invalid body and
payload metadata to /api/playground/keeper-register, assert an HTTP 400
response, and verify both persistence functions receive zero calls.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 246a62f6-78c1-41ee-a909-52000cf5600b

📥 Commits

Reviewing files that changed from the base of the PR and between f2a3bbe and 40ff630.

📒 Files selected for processing (4)
  • app/__tests__/api/keeper-register-metadata-validation.test.ts
  • app/app/api/markets/route.ts
  • app/app/api/playground/keeper-register/route.ts
  • app/lib/market-metadata-validation.ts

Comment on lines +61 to +72
const resolved = typeof raw === "string" && raw.length > 0 ? raw : fallback;
const r = checkSymbol(resolved);
return r.ok ? { ok: true, value: resolved } : { ok: false, error: r.error };
}

/** Resolve `raw` (or `fallback` when raw is empty/absent), then validate as a name. */
export function validateName(
raw: unknown,
fallback: string,
): { ok: boolean; error?: string; value?: string } {
const resolved = typeof raw === "string" && raw.length > 0 ? raw : fallback;
const r = checkName(resolved);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject non-string raw metadata.

validateSymbol and validateName treat {} and 42 as absent input. POST /api/markets can then return success with a fallback value instead of HTTP 400.

Use the fallback only for absent or empty strings. Reject non-null non-string values before resolution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/lib/market-metadata-validation.ts` around lines 61 - 72, Update the
resolution logic in both validateSymbol and validateName functions to explicitly
reject non-null non-string raw values before defaulting to the fallback. Instead
of treating all non-string inputs (like objects or numbers) as absent and using
the fallback, check if raw is null or undefined to use the fallback, check if
raw is a string with length greater than 0 to use raw, and return an error
response for any non-null non-string values to prevent invalid types from being
processed as successful with a fallback value.

@dcccrypto dcccrypto left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed on the PR head (40ff630b). The fix is correct; one gap in the test.

Verified locally

The green Merge Gate is not evidence here — Unit Tests, Integration Tests,
Security Tests and Coverage Gate all report SKIPPED on this PR while the gate
reports SUCCESS (GH#2466's sibling problem, tracked in #2447). So I ran the suite
directly:

cd app && npx vitest run
Test Files  283 passed | 1 skipped (284)
     Tests  2930 passed | 16 skipped (2946)

Clean — no regressions against the playground baseline.

I also checked the substance rather than just the diff:

  • The guard sits before both writes (upsertRegisteredMarketRow and the Blob
    upsertRegisteredMarket), so nothing deceptive can be persisted.
  • All four caller-controlled fields are covered (symbol, label,
    payload.symbol, payload.name); server-derived fallbacks are correctly left
    unvalidated, and the retry path (which sends nulls) is unaffected.
  • The markets/route.ts refactor is behaviour-preserving: symbol || fallback
    and validateSymbol(raw, fallback) resolve identically for every input class.

Gap: the regression test does not bind the fix

keeper-register-metadata-validation.test.ts exercises checkSymbol/checkName
in isolation. It never imports the route, so it cannot fail if the wiring — which
is the fix — is removed. I mutation-tested it: deleting the entire SEC guard
block from app/app/api/playground/keeper-register/route.ts, i.e. fully restoring
the vulnerability, leaves the test at 11 passed / 0 failed.

Drop-in route-level test below. It drives the real handler; with the guard present
it is 6/6 green, and with the guard deleted it fails 5/6 — the request falls
through to the H1 auth error instead of the metadata 400, which is exactly the
binding that is missing. It also asserts no registry write happens.

app/__tests__/api/keeper-register-metadata-route-enforcement.test.ts
/**
 * Regression: the metadata validator must be ENFORCED BY THE ROUTE, not merely
 * exist as a library.
 *
 * keeper-register-metadata-validation.test.ts covers lib/market-metadata-validation
 * in isolation. That unit alone cannot fail if the guard is deleted from
 * app/api/playground/keeper-register/route.ts — i.e. it does not bind the actual
 * fix for GH#2466, which is the WIRING of that validator into the live path.
 *
 * This test drives the real route handler: an impersonation payload must be
 * rejected with 400 and the validator's own error text, before any registry
 * write. Deleting the guard from the route makes this fail (the request instead
 * falls through to the H1 auth check), which is exactly the binding the unit
 * test is missing.
 */
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";

// The route reads NETWORK at module scope — must be devnet before the import.
const prevNetwork = process.env.NEXT_PUBLIC_DEFAULT_NETWORK;
process.env.NEXT_PUBLIC_DEFAULT_NETWORK = "devnet";
afterAll(() => {
  process.env.NEXT_PUBLIC_DEFAULT_NETWORK = prevNetwork;
});

// Any registry write is a failure for this test: the guard runs before them.
const blobPut = vi.fn(async () => ({ url: "https://blob.invalid/x" }));
const supabaseUpsert = vi.fn(async () => ({ error: null }));

vi.mock("@vercel/blob", () => ({
  put: blobPut,
  list: vi.fn(async () => ({ blobs: [] })),
  head: vi.fn(async () => null),
  del: vi.fn(async () => undefined),
}));

vi.mock("@/lib/supabase", () => ({
  getServerNetwork: () => "devnet",
  getServiceClient: () => ({
    from: () => ({
      upsert: supabaseUpsert,
      select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null, error: null }) }) }),
    }),
  }),
}));

vi.mock("@sentry/nextjs", () => ({
  captureException: vi.fn(),
  captureMessage: vi.fn(),
}));

const { POST } = await import("@/app/api/playground/keeper-register/route");

// Real base58 pubkeys so the route's address validation passes and execution
// reaches the metadata guard.
const SLAB = "DJ54k4wH92NTtNP8RuHAwG8si1bevXEknzctDdqYN8eC";
const POOL = "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo";

const post = (body: Record<string, unknown>) =>
  POST(
    new NextRequest("http://localhost/api/playground/keeper-register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ slabAddress: SLAB, dexPoolAddress: POOL, ...body }),
    }),
  );

describe("keeper-register enforces market-metadata validation on the live path", () => {
  beforeEach(() => {
    blobPut.mockClear();
    supabaseUpsert.mockClear();
  });

  const cp = (n: number) => String.fromCodePoint(n);

  const cases: Array<[string, Record<string, unknown>, RegExp]> = [
    ["body symbol — Cyrillic homoglyph", { symbol: "ЅОL" }, /Invalid symbol/],
    ["body label — RTL override", { label: "SOL/USD Perpetual" + cp(0x202e) }, /Invalid name/],
    ["payload.symbol — overlong", { payload: { symbol: "A".repeat(21) } }, /Invalid symbol/],
    ["payload.name — zero-width", { payload: { name: "SOL" + cp(0x200b) + "/USD" } }, /Invalid name/],
    ["payload.name — control char", { payload: { name: "SOL/USD\x00" } }, /Invalid name/],
  ];

  it.each(cases)("rejects %s with 400 and writes nothing", async (_label, body, expected) => {
    const res = await post(body);
    expect(res.status).toBe(400);
    expect(String(((await res.json()) as { error?: string }).error)).toMatch(expected);
    expect(blobPut).not.toHaveBeenCalled();
    expect(supabaseUpsert).not.toHaveBeenCalled();
  });

  it("does not reject legitimate metadata at the metadata guard", async () => {
    const res = await post({ symbol: "SOL", label: "Solana Perpetual" });
    // It must get PAST the metadata guard. It is then stopped by H1 auth
    // (no deployer/signature), which is a different, non-400-metadata outcome.
    if (res.status === 400) {
      const err = String(((await res.json()) as { error?: string }).error);
      expect(err).not.toMatch(/Invalid symbol|Invalid name/);
    }
  });
});

Happy to push this to the branch (maintainerCanModify is on) or open it as a
follow-up PR — your call.

Not a problem: the three red Vercel checks

Vercel – percolator-launch/mainnet/playground all show FAILURE, but their
target_url is a vercel.com/git/authorize?... link — that is the fork-PR
authorization prompt, not a build failure. The base commit f2a3bbe5 is green on
all three, and Type Check and Build & Fast Tests pass here. Nothing to chase.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants