Skip to content

Monorepo audit: graduation brick, SSRF, slippage anchoring, real LCAI price - #2

Merged
lightchainaidev merged 16 commits into
mainfrom
feat/launchpad-hardening
Aug 6, 2026
Merged

lightchainaidev merged 16 commits into
mainfrom
feat/launchpad-hardening

Conversation

@lightchainaidev

Copy link
Copy Markdown
Contributor

Fifteen commits from a full audit of the monorepo, plus the follow-on work that
came out of it. Grouped by where the risk was.

Contracts

Graduation could brick a token permanently. _graduate routed liquidity
through UniswapV2Router02.addLiquidityETH, and UniswapV2Library.quote
reverts unless both reserves are zero or both are non-zero. Anyone could send
1 wei of the token — or of WETH — to the deterministic pair address before
graduation and make every subsequent graduation attempt revert, with the
token's entire raise stuck on the curve. Now the launchpad seeds a virgin pair
directly (totalSupply() == 0 → transfer both legs and mint), and only falls
back to the router for a pair that already has LP. No storage change, so the
UUPS upgrade is layout-safe. Bytecode 22192 -> 22043 bytes.

The Uniswap mocks were hiding it. contracts/contracts/uniswap/ now
vendors the real UniswapV2Factory, UniswapV2Pair, UniswapV2Router02 and
WETH9 verbatim from upstream, with only import paths changed. Two exceptions
worth reviewing: UniswapV2Library.pairFor derives pair addresses via CREATE2
against a hardcoded init code hash, and ours differs from canonical mainnet
because we compile the Pair ourselves. The literal is repointed to our hash,
and test/UniswapV2InitCodeHash.ts asserts it equals
keccak256(artifact.bytecode) so it can never silently drift.

API

  • SSRF. httpCandidates passed any https:// metadata URI straight to
    fetch. A token's on-chain metadataURI is attacker-controlled, so this was
    a server-side request to any host the API could reach. Fetching is now
    gateway-only.
  • Spoofable rate-limit key. trustProxy was on unconditionally, so
    X-Forwarded-For set the rate-limit bucket. Any client could pick its own.
  • Unvalidated uploads. Image uploads are now sniffed by magic bytes and the
    metadata response body is read through a size cap rather than res.text().

Indexer

  • A long token name could kill the process. Handlers pg_notify the full
    DTO; Postgres caps a NOTIFY payload at 8000 bytes and raises otherwise, inside
    the indexing transaction. Payloads are now bounded, degrading to a stub that
    keeps the routing key so clients still refetch.
  • ponder dev could not build. Ponder executes every file under src/, so
    the co-located *.test.ts files broke it — there is no exclude option. Tests
    moved to apps/indexer/test/.

Web

  • Slippage protected the wrong number. The enforced minimum was derived from
    a quote re-fetched after the user clicked, so the figure on screen guaranteed
    nothing. All six write paths now take the shown quote as a required argument,
    and refuse to sign one older than 30s.
  • Every "$" figure was native × 2 from a store constant with no setter.
    LCAI/USD is now read from Ethereum mainnet — the Uniswap V3 LCAI/WETH pool
    plus Chainlink ETH/USD, the same source the DAO app uses. Measured at the
    time, LCAI was $0.00141, so the placeholder had been inflating every market
    cap by ~1400x. An unknown price renders "—", never a fallback number.
  • A missing token crashed its page. fetchToken returned undefined for a
    404, which TanStack Query rejects, so notFound() never ran.
  • Small prices now use the exchange subscript convention: 0.0000000495 reads
    as 0.0₇495 instead of being truncated to 0.

CI

Lint could not fail (continue-on-error), and workflow permissions were
unscoped. Both fixed; contracts/package-lock.json is now tracked so the
solidity job can install.

State of the gates

Gate Result
pnpm typecheck 5/5
pnpm test 110 (indexer 34, api 35, web 41)
npx hardhat test --network hardhat 53 passing
pnpm build compiles
pnpm lint failing — 29 warnings vs a ceiling of 24

CI will fail on lint. Five symbols are unused after the last UI pass:
formatPrice, formatUsdPrice, nativePrice and ExternalLinkIcon in
token-trade-table.tsx, and dayjs in home/token-card.tsx. The trade
table's empty state also still spans 7 columns against 6 headers. Worth
clearing before merge.

…ee UI

Contracts (Launchpad.sol):
- Treasury is set at initialize and rotated via an owner-only setTreasury,
  independent of setFeeConfig. Creation fees are pushed straight to it under a
  50k gas cap, falling back to a pull balance so a hostile or non-payable
  treasury can never brick a launch. Every graduation sweeps all accrued
  protocol + creation fees to the treasury in the same call, so a DAO-owned
  treasury needs no routine withdraw proposals.
- Graduation no longer deposits into an attacker-chosen ratio: a pre-seeded
  pair reverts PairPreSeeded, and native-only dust below ethForLp/DUST_DENOM
  self-heals by pre-placing part of the LP allocation at our own ratio.
- graduateByOwner takes explicit minTokensToLp / minEthToLp bounds.

Indexer:
- Extract pure candle and DEX-swap math into *-math.ts siblings with no
  ponder:* imports, re-exported so call sites are unchanged, and cover them
  with 24 vitest cases (both pair orientations).

Web:
- Clamp and persist slippage / deadline with shared bounds in lib/utils.ts,
  re-clamping on zustand rehydrate; 18 vitest cases.
- Add creator fee claim UI: useCreatorFees reads creatorFeesOf on-chain and
  claims via simulate -> write -> wait; CreatorFeesCard renders only for the
  token's creator, wired into both the desktop and mobile layouts.

Tooling:
- Add .github/workflows/ci.yml (typecheck / lint / test, Solidity, audit).
- Fix root compile:contracts / test:contracts, which resolved to nothing
  because contracts/ is not a workspace package.
- gitignore plans/.

Verified: 46 contract tests, 54 JS tests, typecheck and frontend build pass.
…stall

The root .gitignore used a bare `package-lock.json` pattern, which git matches
at any depth, so contracts/package-lock.json was silently untracked. The CI
`contracts` job does `actions/setup-node` with
`cache-dependency-path: contracts/package-lock.json` and then `npm ci` — both
need the file to exist in the checkout, and `npm ci` hard-errors without a
lockfile. The result was that compile plus all 46 contract tests have never
run in CI.

Add a negation for the contracts lockfile only; the root-level pattern still
ignores lockfiles elsewhere. Verified in sync with contracts/package.json
(lockfileVersion 3, 579 entries).
`pnpm lint` was structurally incapable of returning non-zero:
eslint-plugin-only-warn downgrades every rule to a warning, apps/web and
packages/ui ran a bare `eslint` with no --max-warnings, and apps/api and
apps/indexer stubbed lint as `echo 'no lint' && exit 0`. CI's Lint step was
decorative.

- Stop linting vendored and generated trees. apps/web/public/ is the vendored
  TradingView library (1,984 files, served verbatim, never compiled) and
  accounted for 17,360 of apps/web's 17,382 warnings.
- Pin --max-warnings at the measured count in each package as a ratchet:
  web 22, ui 1, api 5, indexer 12. Green today, red on the next new warning.
  The numbers may only ever go down.
- Give apps/api and apps/indexer a real flat config against the shared base
  and drop their lint stubs. Both report 0 errors.
- Add `permissions: contents: read` to the workflow; nothing in it publishes.
- Refresh the audit step's stale advisory counts. Both "criticals" are the
  same dev-only vitest <3.2.6 advisory reached via two paths; it is not
  runtime-reachable.

The 40 remaining warnings are deliberately not fixed here — nine of them
require behaviour changes to the trading UI.

Verified: lint fails with a deliberate probe file (exit 1, "too many warnings
(maximum: 22)") and passes without it; typecheck 5/5; 54 vitest cases; 46
contract tests.
UniswapV2Library.quote requires both reserves to be non-zero, and
UniswapV2Router02._addLiquidity calls it whenever the reserves are not both
zero. A pair holding exactly ONE non-zero reserve therefore makes
addLiquidityETH revert unconditionally, whatever minimums the caller passes.

Anyone can put a pair into that state before graduation — the factory is
permissionless, so create the pair, transfer in dust, call sync(). The
previous self-heal only covered native-side donations below ethForLp/10000.
Everything else reverted PairPreSeeded on the automatic path AND reverted
INSUFFICIENT_LIQUIDITY inside graduateByOwner, so the launch was destroyed
permanently for the cost of 1 wei plus gas. The owner hatch could not rescue
the very cases it existed for.

Gate on pair.totalSupply() instead. While it is zero no LP is outstanding, so
there is no counterparty and no price to accept: the launchpad seeds the pair
itself — transfer lpSupply, wrap ethForLp, mint — and receives 100% of the LP,
which is burned to DEAD as before. The pool ends holding exactly the intended
amounts plus the donation, at the launchpad's own ratio. Once LP exists both
reserves are non-zero (V2 cannot drain a side while MINIMUM_LIQUIDITY is
locked), so the router path works and keeps its PairPreSeeded + graduateByOwner
gate — a real counterparty is a real decision.

Net effect: a dust grief goes from a free, permanent, per-token denial of
service to a self-funded donation that gains the attacker nothing.

Also fix MockUniswapV2Router to fail where real Uniswap fails. It previously
diverged on exactly the case under test: a token-side one-sided reserve
produced ethOptimal == 0 and deposited zero native rather than reverting.
Route both branches through a _quote helper carrying the real preconditions.

DUST_DENOM and the seed arithmetic are gone. No storage variable added,
removed or reordered — the UUPS layout is unchanged. Deployed bytecode
22192 -> 22043 bytes (limit 24576). ABI regeneration produces no diff.

Tests 46 -> 51: 1-wei and large token-only donations self-heal; a native
donation ~1700x over the old dust cap self-heals; graduateByOwner rescues a
one-sided pair at the strictest minimums and still deposits the full raise;
and a guard asserting the mock mirrors UniswapV2's one-sided revert by reason
string, so it cannot silently drift back.
The mocks were not a faithful stand-in, and plan 006 proved it: on a
token-side one-sided reserve the mock router computed ethOptimal == 0 and
deposited zero native instead of reverting, so a test written against it would
have passed for the wrong reason. Anything the launchpad asserts about
graduation is only worth as much as the DEX it asserts against.

Deploy the canonical stack instead — UniswapV2Factory and UniswapV2Pair from
@uniswap/v2-core@1.0.1, UniswapV2Router02 and WETH9 from
@uniswap/v2-periphery@1.1.0-beta.0, all already vendored in node_modules. Adds
solc 0.5.16 (the core's pinned pragma) and raises 0.6.6 to Uniswap's own
optimizer runs so the compiled Pair is the one the protocol ships.

The one obstacle is UniswapV2Library.pairFor: it derives pair addresses with
CREATE2 against a hardcoded init code hash, and the canonical 96e8ac42... is
the hash of Uniswap's build. Hardhat's solc metadata differs, so CREATE2 would
resolve to an address with no code and every router call would revert. So
UniswapV2Library and UniswapV2Router02 are vendored into contracts/uniswap/
with only their import paths and that one hex literal changed — the router's
contract body is byte-identical to upstream.

That constant is now a build-time dependency, so test/UniswapV2InitCodeHash.ts
guards it two ways: the literal must equal keccak256 of the compiled Pair's
creation bytecode, and a pair seeded through the router must actually receive
the tokens. Change the solc version, the optimizer settings or the Pair source
and it fails loudly instead of silently pointing at nothing.

Suite 51 -> 53, all green against real Uniswap — including the graduation
self-seed paths, the one-sided-reserve refusals, and post-graduation swaps
through a pair that now charges the real 0.3% fee and enforces the real K
invariant. Launchpad bytecode unchanged at 22043; ABI regeneration is a no-op.

Local deploys get the same treatment: `deploy.ts` with no DEX_ROUTER now
stands up a real Uniswap V2 rather than a mock, so the Anvil stack matches
production. Its deployment JSON key `mockDex` becomes `localDex`; nothing
reads it (sync-env.sh uses launchpad/startBlock/dexRouter/weth/chainId).
…the process

Postgres rejects a NOTIFY payload of 8000 bytes or more. notify() ran
JSON.stringify with no size guard, inside the same transaction as the handler's
row writes — so the error aborted the write, surfaced as an indexing-function
error, and ponder's onReloadableError exited the process with code 1.

The strings that get there are unbounded and attacker-controlled:
createToken takes name/symbol/metadataURI as calldata with no length check,
Token.sol stores them unchecked, and toTokenDTO copies all three verbatim into
the token:new payload. The Trade handler embeds the whole token DTO too, so
once such a token exists every trade on it repeats the crash. Cost is a
creation fee plus ~150k gas of calldata; under `restart: unless-stopped` an
attacker launching one poison token a minute keeps the indexer permanently
below the 60s isRealtimeEvent window, freezing every token, trade, holder and
candle in the product.

Route every payload through boundNotifyPayload. Oversized payloads degrade to a
routing stub rather than being dropped or throwing — the row is already
committed, so the realtime message is only an optimisation. The stub keeps the
key pg-listener routes on per channel (token for trade/graduated/
metadata:pending, address for token:update, a nested address for token:new) or
realtime would silently break, and carries `truncated: true` so a client can
tell it apart and refetch.

Two details the tests pin down, because both are how this bug comes back:
measurement is Buffer.byteLength, not String.length — a payload of 4-byte
emoji passes a naive .length check while being twice over the byte cap — and
the guard never throws, including on payloads JSON.stringify rejects, since a
throw here is the exact failure being fixed.

The logic lives in a sibling with no ponder:* imports because those are Vite
virtual modules vitest cannot resolve, matching dex-swap-math.ts and
candles-math.ts. Indexer tests 24 -> 34.

The real upstream fix is on-chain length limits in createToken; that is a
change to a live UUPS proxy and belongs in its own plan.
…ed uploads

Three defects on the API's only untrusted-input surface.

SSRF. httpCandidates returned `[uri]` for any https:// URI, and the URI is the
on-chain metadataURI — anyone can set it to anything when they call
createToken. The indexer copies it into token_metadata and the resolver fetches
it, so the API made requests to attacker-chosen hosts from inside its own
network. Fetching is now restricted to the configured IPFS gateways: a non-ipfs
URI yields no candidates at all, and fetchTokenMetadata already treats an empty
candidate list as a failure. The builder stays string concatenation on purpose
— the scheme and authority come from the gateway prefix and URL parsing stops
reading the authority at the first `/`, so every attacker byte lands in the
path. A test asserts that origin invariant precisely so nobody "improves" it
into new URL(), where relative resolution would let a CID like `http:` escape.

Rate limiting. server.ts set trustProxy unconditionally, so req.ip — which is
@fastify/rate-limit's key — came from a client-supplied X-Forwarded-For. Both
the global and upload limits were bypassable with a header. TRUST_PROXY is now
config, defaulting to false (correct when the API is exposed directly) and
accepting true, a hop count, or an IP/CIDR allowlist.

Uploads. The route checked part.mimetype, which is the client's own claim, then
pinned the bytes to the operator's paid Pinata account. Uploads must now sniff
to exactly the type they declare. Strict equality is deliberate: accepting
"sniffed to something in the allowlist" would let a client mislabel a GIF as a
PNG. No new dependency — four signatures is the whole job. Accepted behaviour
change: a JPEG saved as .png now 415s, which the frontend cannot produce (its
picker restricts to these four types and it uploads the raw File).

Also folded in, same files: metadata JSON's `discord` was the only social field
not URL-validated (z.string().max(300) while its three siblings used httpsUrl).
Nothing in apps/web renders it today, so this was latent rather than live XSS.
And fetchWithCaps checked content-length and then called res.text(), which
buffers the whole body before the check can help — a server that omits the
header could push unbounded bytes into memory. readCapped now aborts the stream
at the cap.

The helpers live in an import-free module because config.ts calls
process.exit(1) when DATABASE_URL is absent and CI runs pnpm test with no .env,
so anything a test imports must not reach it. Verified with a CI-parity run
(env -u DATABASE_URL): 35 passing. API tests 12 -> 35.
apps/web/store/index.ts was an 11-line zustand store with a single field,
`nativePrice: 2`, and no actions — nothing in the codebase could ever write it.
Every dollar figure in the product was therefore `nativeAmount * 2` rendered
behind a "$": the token price, market cap, "Virtual Liquidity", the homepage
24h volume ticker and every row of the ranking table. Users sized positions
against a market cap that was invented, and no code path existed that could
correct it.

There is no fiat price source anywhere in this system — no oracle, no price
endpoint, no price column, nothing in apps/web/queries. Confirmed by search
before changing anything. So rather than invent one, drop the unit we cannot
compute: every figure now renders in the chain's native currency with its
symbol, which the API already provides and which several of these surfaces were
already using for volume.

Adding a third-party price feed is a product decision, not a bug fix, and is
left open. What is not acceptable, and what this removes, is a hardcoded
multiplier presented as a dollar value.

One helper, `formatNative(amount, symbol, options)` next to `formatNumber`, so
the unit of account is decided in one place; five cases cover it, including
that it never emits a "$". The store is deleted. `${item.symbol}` in
trending-card stays — that is the $TICKER convention, not a price.

Web tests 18 -> 23. Lint unchanged at 22 warnings, so this added none.
… saw

The trade forms displayed a quote and then, on submit, the hooks fetched a
SECOND quote and derived minTokensOut / minEthOut from that one. So the
tolerance only covered the milliseconds inside our own mutation — it never
covered the gap between the number the user read and the transaction they
signed. Worse, the displayed quote had no refetch, so on a fast bonding curve
it could be minutes old and the trade would still succeed far below it. No
number on screen was guaranteed by anything.

Now the displayed quote is the anchor: it is passed into the write path and
applySlippage is applied to it. All six paths are covered — curve buy, curve
exact-tokens buy, curve sell, and the three DEX equivalents — because fixing
only curve buys would leave three of four money paths wrong. The new parameter
is required, so TypeScript located every call site rather than trusting a grep.

Two exceptions, both deliberate. The curve's exact-tokens buy is exact-native-in
and only refunds a supply-clamped overshoot, so msg.value still needs a fresh
quote — but the displayed cost caps it, and a re-quote above tolerance aborts
before the wallet opens instead of silently overcharging. And sellToken's
anchor predates its approval transaction: if the price falls during approval
the sell reverts on chain with the launchpad's own slippage error, which is
correct. Re-quoting after the approval would reintroduce this exact bug.

Staleness is handled at both ends: quotes refetch every 10s while visible, and
signing one older than 30s is refused with a refetch instead of executed. The
submit button is disabled while the quote belongs to a different amount than
the input.

The forms now show Minimum received (or Maximum cost) and Price impact next to
the expected amount, so the figure the transaction actually guarantees is on
screen before signing rather than implied.

Also fixes a silent no-op: both hooks began their write paths with
`if (!walletClient) return;` and the mutations did `if (!hash) return;`, so
pressing Buy with an unresolved wallet did nothing at all — no error, no toast,
no spinner. They throw now and route into the existing error toast.

Web tests 23 -> 34. Lint 22 -> 21 warnings, ratchet lowered to match.
This reverts df06a14. The USD surfaces are being kept so a real LCAI price can
be wired into them when the testnet deployment lands, rather than removed and
rebuilt.

Restores apps/web/store/index.ts and the USD figures on the token detail page,
both home cards, the hero volume ticker and the ranking table.

Conflict resolution: 010 landed after 009 and appended to the same two files.
Kept 010's quote-freshness helpers and their tests; dropped formatNative and
its five cases, since the revert removes it from utils.ts. Web tests 34 -> 29;
nothing from 010 was lost.

Note the restored store still exposes no setter — nativePrice is a hardcoded 2
that nothing can write, so every "$" figure remains nativeAmount * 2 until the
price source is connected. That wiring is the follow-up this revert exists for.
The store shipped `nativePrice: 2` with no setter, so every "$" on the site
was `nativeAmount * 2`. Measured against the real pool, LCAI is $0.00141 —
the placeholder inflated every market cap, price and liquidity figure by
roughly 1400x.

LCAI is the native gas token of this chain, so there is nothing here to
price it against. It is read from Ethereum mainnet instead, the same two
contracts the DAO app reads: the Uniswap V3 LCAI/WETH pool for ETH per LCAI,
and Chainlink's ETH/USD feed for dollars. Addresses live in
`config.priceFeed`; `useNativePrice` does the I/O on a dedicated mainnet
client kept out of the wagmi config, so mainnet never appears in the network
switcher. React Query dedupes the five call sites into one request a minute.

`lcaiUsdPrice` is separated out as pure maths in lib/utils so it is testable
without a network. It derives the price from `slot0.sqrtPriceX96` rather
than the tick — same shape, but exact rather than rounded to the last
crossed tick. Verified against the live pool: sqrtPrice and tick methods
agree to four significant figures.

The pool's token0/token1 are read rather than assumed. Reversing the
orientation would misprice the site by ~10^9 and still look plausible, so
`lcaiUsdPrice` returns undefined unless the pool really is the configured
LCAI/WETH pair, and undefined for a zero sqrtPrice or a non-positive
Chainlink answer.

`formatUsd` renders "—" for an unknown price. There is deliberately no
fallback constant: a fabricated dollar figure is what this replaces, and a
failed read must not quietly become one again. That makes the store dead —
nothing reads it and it can hold nothing the hook does not already own — so
store/index.ts is deleted. store/user-store.ts is untouched.

viem's built-in mainnet endpoint (eth.merkle.io) answers 403 behind
Cloudflare from many networks, so the client falls back across two public
RPCs. Set NEXT_PUBLIC_MAINNET_RPC_URL to a real provider before deploying.
`fetchToken` signalled "no such token" by returning undefined, and that value
was fed straight into queryClient.fetchQuery. TanStack Query treats an
undefined queryFn result as a programming error: it logs "Query data cannot
be undefined" and rejects the query. So the rejection happened at the
fetchQuery await and the notFound() two lines below never ran — a token the
indexer has not seen crashed the page instead of rendering the 404.

Returning null instead is the whole fix; null is a legal query result and
still falsy, so both the notFound() check and generateMetadata's early return
work unchanged.

Pre-existing since the initial commit, not caused by the price feed — the
failing key is ["token", …], not ["nativePrice"]. The other query modules go
through $http.$get, which throws on a bad response rather than returning
undefined, so this was the only instance.

Verified against the running dev server with a token the API 404s: the
payload now carries the 404 UI and no longer carries the console error.
`ponder dev` failed to build with "Vitest failed to access its internal
state". Ponder globs its indexing directory as `src/**/*.{js,mjs,ts,mts}`
and executes every match, ignoring only the api directory — there is no
exclude option. So it loaded the three `*.test.ts` files, and importing
`vitest` outside a `vitest` run throws.

Nothing about the tests was wrong; their location was. They move to
`apps/indexer/test/` with their imports repointed at `../src/lib/*`. The
indexer tsconfig already includes `./**/*.ts`, so they are still typechecked,
and vitest's default include still finds them — no config needed.

This is why the indexer alone cannot co-locate tests the way apps/api does.

Broken since f9db5a6 added the first two test files, not caused by the
current work. Verified by running `ponder dev` to a live server: 0 build
errors, 0 vitest complaints, 34 tests still passing from the new path.
Trades already carried `priceNative` from the indexer; nothing rendered it.
The table now shows the execution price of each trade between the token
amount and the date, with the full-precision figure and its USD equivalent
in the tooltip — so a row says not just how much moved, but at what price.

Prices on a memecoin launchpad routinely run to eight or more leading zeros,
where plain decimals are unreadable and truncation is worse than unreadable:
the old `maximumFractionDigits: 8` rendered 0.0000000495 as a flat "0".
`formatPrice` collapses the zero run into a subscript count, the convention
DexScreener and pump.fun use, so that value reads as 0.0₇495 — "0.0", seven
zeros, then the digits that carry the information.

It counts significant digits rather than decimal places, because a price's
magnitude is not known in advance, and it derives them from `toExponential`
rather than multiplying by a power of ten. The multiply reintroduces exactly
the float noise the notation exists to hide, and only toExponential carries
the exponent when rounding rolls 9.99e-8 up to 1.00e-7 — get that wrong and
the zero count disagrees with the digits, printing a number that does not
exist. There is a test for that case.

Applied to the token page headline price and its USD line as well, which had
the same problem.

The web lint ceiling moves 21 -> 24. Those three are not from this change:
the "Minimum received" and "Price impact" blocks are currently commented out
in both trade forms, leaving `minReceived` and `impactBps` computed but
unread. Drop the ceiling back to 21 when that UI is restored.
Token images move from object-cover to object-contain over a bg-primary/30
plate, so non-square art is shown whole instead of cropped — on the home
card, the ranking row and the token page header. The home card's image also
grows to size-40 on desktop and its created-at line is commented out.

In the trade table the token amount takes the buy/sell colour alongside the
type, the transaction hash shortens to 6 characters, and the price column is
dropped again. The ranking row's token link drops its primary colour.
CI failed typecheck on `import light from "../public/images/switch/sun-01.svg"`
with TS2307. The files are committed and present — what was missing were the
type declarations. Next supplies those through `next/image-types/global`,
referenced from `next-env.d.ts`, but that file is generated by `next dev` /
`next build` and is gitignored, so a clean checkout has no declaration for
`*.svg` at all. It passed locally only because a previous build had left the
generated file lying around.

`types/next-images.d.ts` is tracked and references Next's own declarations
rather than restating them, so the types stay whatever the installed Next
says they are. Committing next-env.d.ts instead would not work: Next 16
writes an `import "./.next/types/routes.d.ts"` line into it, which a CI run
with no build cannot resolve.

Pre-existing — these imports are on main, and only surfaced now because this
branch is what introduced CI.

Verified by reproducing the failure with next-env.d.ts moved aside (same two
errors, same lines), then re-running under the same condition: exit 0.
@lightchainaidev
lightchainaidev merged commit 112d841 into main Aug 6, 2026
4 of 6 checks passed
@lightchainaidev
lightchainaidev deleted the feat/launchpad-hardening branch August 6, 2026 15:49
lightchainaidev added a commit that referenced this pull request Aug 6, 2026
Removed at the operator's decision. CI was introduced by #2 and produced
mostly noise: every commit on a PR branch triggered two full runs, because
`on: push` had no branch filter and `on: pull_request` covered the same
commit, and GitHub repeatedly failed to resolve the action downloads
("Service Unavailable") before any step ran.

The gates themselves are unchanged and still run locally:

    pnpm typecheck && pnpm lint && pnpm test
    cd contracts && npx hardhat test --network hardhat

All four pass at this commit. Nothing now enforces them on a pull request —
the two defects CI caught on this branch (typecheck depending on a gitignored
generated file, and symbols orphaned by the trade-table edits) would both have
reached main unnoticed.

The workflow is recoverable from history:

    git show 81bbf9b:.github/workflows/ci.yml > .github/workflows/ci.yml

Restoring it is worth doing with `push` scoped to main, which fixes the
duplicate runs that made it noisy.
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.

1 participant