Monorepo audit: graduation brick, SSRF, slippage anchoring, real LCAI price - #2
Merged
Merged
Conversation
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
_graduaterouted liquiditythrough
UniswapV2Router02.addLiquidityETH, andUniswapV2Library.quotereverts 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 andmint), and only fallsback 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/nowvendors the real
UniswapV2Factory,UniswapV2Pair,UniswapV2Router02andWETH9verbatim from upstream, with only import paths changed. Two exceptionsworth reviewing:
UniswapV2Library.pairForderives pair addresses via CREATE2against 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.tsasserts it equalskeccak256(artifact.bytecode)so it can never silently drift.API
httpCandidatespassed anyhttps://metadata URI straight tofetch. A token's on-chainmetadataURIis attacker-controlled, so this wasa server-side request to any host the API could reach. Fetching is now
gateway-only.
trustProxywas on unconditionally, soX-Forwarded-Forset the rate-limit bucket. Any client could pick its own.metadata response body is read through a size cap rather than
res.text().Indexer
pg_notifythe fullDTO; 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 devcould not build. Ponder executes every file undersrc/, sothe co-located
*.test.tsfiles broke it — there is no exclude option. Testsmoved to
apps/indexer/test/.Web
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.
native × 2from 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.
fetchTokenreturnedundefinedfor a404, which TanStack Query rejects, so
notFound()never ran.0.0000000495readsas
0.0₇495instead of being truncated to0.CI
Lint could not fail (
continue-on-error), and workflow permissions wereunscoped. Both fixed;
contracts/package-lock.jsonis now tracked so thesolidity job can install.
State of the gates
pnpm typecheckpnpm testnpx hardhat test --network hardhatpnpm buildpnpm lintCI will fail on lint. Five symbols are unused after the last UI pass:
formatPrice,formatUsdPrice,nativePriceandExternalLinkIconintoken-trade-table.tsx, anddayjsinhome/token-card.tsx. The tradetable's empty state also still spans 7 columns against 6 headers. Worth
clearing before merge.