Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f9db5a6
feat: treasury redesign, graduation hardening, CI baseline, creator-f…
lightchainaidev Aug 5, 2026
4d228aa
fix(ci): track contracts/package-lock.json so the solidity job can in…
lightchainaidev Aug 6, 2026
4612b73
fix(ci): make lint able to fail; harden workflow permissions
lightchainaidev Aug 6, 2026
2d143b9
fix(contracts): make graduation survive any one-sided pre-seeded pair
lightchainaidev Aug 6, 2026
7cc4762
test(contracts): replace the Uniswap V2 mocks with the real contracts
lightchainaidev Aug 6, 2026
37ee4f4
fix(indexer): bound NOTIFY payloads so a long token name cannot kill …
lightchainaidev Aug 6, 2026
30aa832
fix(api): close the SSRF, the spoofable rate-limit key, and unvalidat…
lightchainaidev Aug 6, 2026
df06a14
fix(web): stop rendering fabricated USD figures
lightchainaidev Aug 6, 2026
95a1059
fix(web): anchor the enforced slippage to the quote the user actually…
lightchainaidev Aug 6, 2026
8604d51
Revert "fix(web): stop rendering fabricated USD figures"
lightchainaidev Aug 6, 2026
4be051d
feat(web): read LCAI/USD from the mainnet pool instead of a hardcoded 2
lightchainaidev Aug 6, 2026
4c1805f
fix(web): return null, not undefined, for a token the API will not serve
lightchainaidev Aug 6, 2026
c0dc9ed
fix(indexer): move tests out of src/ so ponder dev can build
lightchainaidev Aug 6, 2026
e409db9
feat(web): add a price column to the trade table and compact tiny prices
lightchainaidev Aug 6, 2026
e9ccf75
style(web): letterbox token art, tighten the trade table
lightchainaidev Aug 6, 2026
9b300dc
fix(web): declare static image imports without relying on next-env.d.ts
lightchainaidev Aug 6, 2026
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ FINALITY_BLOCK_COUNT=0
# API
PUBLIC_URL=http://localhost:3001
CORS_ORIGIN=*
TRUST_PROXY=false
PINATA_JWT=
PINATA_GATEWAY=gateway.pinata.cloud

Expand Down
123 changes: 123 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
name: ci

on:
push:
pull_request:

# This workflow only reads the repo — nothing publishes, comments, or deploys.
permissions:
contents: read

# Cancel superseded runs on the same ref.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
# ---------------------------------------------------------------------------
# Workspace packages (apps/*, packages/*) — pnpm.
# `contracts/` is deliberately NOT a workspace package; it has its own job.
# ---------------------------------------------------------------------------
js:
name: typecheck / lint / test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

# @lcai/abis is generated-but-committed and imported by api, indexer and
# web. It must be built before anything typechecks against it.
- name: Build @lcai/abis
run: pnpm --filter @lcai/abis build

- name: Typecheck
run: pnpm typecheck

- name: Lint
run: pnpm lint

- name: Test
run: pnpm test

# ---------------------------------------------------------------------------
# Solidity — npm, not pnpm. `contracts/` has its own package-lock.json.
# ---------------------------------------------------------------------------
contracts:
name: solidity
runs-on: ubuntu-latest
defaults:
run:
working-directory: contracts
steps:
- uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: contracts/package-lock.json

- name: Install dependencies
run: npm ci

- name: Compile
run: npx hardhat compile

# `--network hardhat` is MANDATORY. hardhat.config.ts sets
# `defaultNetwork: "lcaiTestnet"`, which points at the live public testnet
# (https://rpc.testnet.lightchain.ai). Omitting the flag targets it.
# ALCHEMY_API_KEY is intentionally unset: without it forking is disabled
# and the suite runs entirely against the in-repo Uniswap V2 mocks.
- name: Test
run: npx hardhat test --network hardhat

# ---------------------------------------------------------------------------
# Dependency advisories — reported, not enforced.
# ---------------------------------------------------------------------------
audit:
name: dependency audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

# Non-blocking. As of f9db5a6 `pnpm audit --audit-level high` reports
# `4 low | 57 moderate | 42 high | 2 critical` — those are path
# instances; deduplicated it is 1 unique critical and 38 unique high
# (84 unique advisories in total).
# Both "criticals" are the same vitest <3.2.6 advisory, reached once via
# apps/web and once via apps/api. It is dev-only and exploitable only
# while the Vitest UI server is listening, so it is NOT runtime-reachable.
# Flip to blocking once the runtime-reachable advisories (next,
# @fastify/static, ws, socket.io-parser, find-my-way, drizzle-orm) are
# cleared.
- name: Audit
run: pnpm audit --audit-level high
continue-on-error: true
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ typechain-types/
*.log
coverage/
package-lock.json
# …but contracts/ is outside the pnpm workspace and installs with npm.
# `npm ci` refuses to run without a lockfile, so this one must be tracked.
!contracts/package-lock.json
yarn.lock


Expand Down Expand Up @@ -77,3 +80,4 @@ CLAUDE.md
.claude/
.cursor/
.agents/
plans/
6 changes: 6 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ IPFS_GATEWAYS="https://gateway.pinata.cloud/ipfs/,https://ipfs.io/ipfs/,https://
MAX_IMAGE_BYTES="4194304"
RATE_LIMIT_GLOBAL_PER_MIN="300"
RATE_LIMIT_UPLOAD_PER_MIN="10"

# Fastify trustProxy. Leave "false" when the API is exposed directly.
# Set ONLY when a proxy you control always OVERWRITES X-Forwarded-For — req.ip
# is the rate-limit key, so a spoofable XFF means no rate limit at all.
# false | true | <hop count e.g. 1> | <IP/CIDR list e.g. "10.0.0.0/8,127.0.0.1">
TRUST_PROXY="false"
4 changes: 4 additions & 0 deletions apps/api/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { config } from "@lcai/eslint-config/base"

/** @type {import("eslint").Linter.Config} */
export default config
4 changes: 3 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"lint": "echo 'no lint' && exit 0"
"lint": "eslint --max-warnings 5"
},
"dependencies": {
"@fastify/cors": "^10.0.1",
Expand All @@ -33,7 +33,9 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@lcai/eslint-config": "workspace:*",
"@types/node": "^22.10.0",
"eslint": "^9",
"pino-pretty": "^13.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import "dotenv/config";
import { z } from "zod";

import { parseTrustProxy } from "./services/untrusted-input.js";

const csv = (def: string) =>
z.string().default(def).transform((v) => v.split(",").map((s) => s.trim()).filter(Boolean));

Expand All @@ -13,6 +15,13 @@ const schema = z.object({
CORS_ORIGIN: z.string().default("*"),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),

// Fastify `trustProxy`. Default false — correct when the API is exposed
// directly. `req.ip` is the rate-limit key, so trusting a client-supplied
// X-Forwarded-For means there is effectively no rate limit at all. Set this
// ONLY when a proxy you control always OVERWRITES that header.
// false | true | <hop count, e.g. 1> | <IP/CIDR list, e.g. "10.0.0.0/8,127.0.0.1">
TRUST_PROXY: z.string().default("false").transform(parseTrustProxy),

RPC_URL: z.string().url().default("http://127.0.0.1:8545"),

// The Launchpad proxy address. Used for the holders view: every launched token
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/routes/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { FastifyPluginAsync } from "fastify";
import { config } from "../config.js";
import { uploadFormSchema, buildCanonicalMetadata } from "../services/metadata-schema.js";
import { pinFile, pinJson, pinningEnabled, PinningDisabledError } from "../services/ipfs.js";
import { sniffImageType } from "../services/untrusted-input.js";

const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);

Expand Down Expand Up @@ -38,6 +39,15 @@ const plugin: FastifyPluginAsync = async (app) => {
if ((part as { file?: { truncated?: boolean } }).file?.truncated) {
return reply.code(413).send({ error: "image too large" });
}
// `part.mimetype` is the client's own claim. Pin only bytes that are
// actually the image type they say they are — otherwise the upload
// endpoint pins arbitrary content to the operator's Pinata account.
const sniffed = sniffImageType(buffer);
if (sniffed !== part.mimetype) {
return reply
.code(415)
.send({ error: `image bytes are not a valid ${part.mimetype}` });
}
image = { buffer, filename: part.filename || "image", mimetype: part.mimetype };
} else {
const v = part.value as string;
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export async function buildServer(): Promise<FastifyInstance> {
config.NODE_ENV === "development"
? { level: config.LOG_LEVEL, transport: { target: "pino-pretty", options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" } } }
: { level: config.LOG_LEVEL },
trustProxy: true,
trustProxy: config.TRUST_PROXY,
bodyLimit: 1 * 1024 * 1024,
});

Expand Down
43 changes: 25 additions & 18 deletions apps/api/src/services/ipfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { PinataSDK } from "pinata";

import { config } from "../config.js";
import { tokenMetadataJsonSchema, type TokenMetadataJson } from "./metadata-schema.js";
import { metadataFetchUrls, parseIpfsUri } from "./untrusted-input.js";

export { parseIpfsUri };

const pinata = config.PINATA_JWT
? new PinataSDK({ pinataJwt: config.PINATA_JWT, pinataGateway: config.PINATA_GATEWAY })
Expand Down Expand Up @@ -43,27 +46,33 @@ export async function repinCid(cid: string): Promise<void> {
}
}

/** Parse `ipfs://<cid>[/path]` into `[cid, path]`. Returns null for non-ipfs URIs. */
export function parseIpfsUri(uri: string): { cid: string; path: string } | null {
const m = /^ipfs:\/\/([^/]+)(\/.*)?$/i.exec(uri.trim());
if (!m) return null;
return { cid: m[1]!, path: m[2] ?? "" };
}

/** Build candidate HTTP URLs for a metadata URI, gateways-first for ipfs:// */
/** Build the URLs the resolver may fetch for a metadata URI: configured IPFS gateways only. */
export function httpCandidates(uri: string): string[] {
const t = uri.trim();
const ipfs = parseIpfsUri(t);
if (ipfs) {
return config.IPFS_GATEWAYS.map((g) => `${g.replace(/\/$/, "")}/${ipfs.cid}${ipfs.path}`);
}
if (/^https:\/\//i.test(t)) return [t];
return [];
return metadataFetchUrls(uri, config.IPFS_GATEWAYS);
}

const MAX_JSON_BYTES = 64 * 1024;
const FETCH_TIMEOUT_MS = 5_000;

/** Read at most `max` bytes, aborting the stream rather than buffering past it. */
async function readCapped(res: Response, max: number): Promise<string> {
if (!res.body) return "";
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.length;
if (total > max) {
await reader.cancel();
throw new Error("metadata too large");
}
chunks.push(value);
}
return Buffer.concat(chunks).toString("utf8");
}

async function fetchWithCaps(url: string): Promise<unknown> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
Expand All @@ -72,9 +81,7 @@ async function fetchWithCaps(url: string): Promise<unknown> {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const len = Number(res.headers.get("content-length") ?? "0");
if (len > MAX_JSON_BYTES) throw new Error("metadata too large");
const text = await res.text();
if (text.length > MAX_JSON_BYTES) throw new Error("metadata too large");
return JSON.parse(text);
return JSON.parse(await readCapped(res, MAX_JSON_BYTES));
} finally {
clearTimeout(timer);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/services/metadata-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const tokenMetadataJsonSchema = z.object({
website: httpsUrl,
twitter: httpsUrl,
telegram: httpsUrl,
discord: z.string().trim().max(300).optional(),
discord: httpsUrl,
tags: tags,
});
export type TokenMetadataJson = z.infer<typeof tokenMetadataJsonSchema>;
Expand Down
Loading
Loading