Limit build-only CI tokens to read-only contents permissions.
Clarify intentional control filtering without changing signed post text,
and replace the sitemap test hostname-prefix check with exact origins.
Cover the image-preview React attribute boundary without changing its
runtime behavior or dismissing the existing scanner warning.
The indexer stores a swap with a null trader when the sender lookup fails
(a rate-limited node during a busy launch block). The holder panel then
called toLowerCase on it, the token page's error boundary replaced the
whole page, and /api/launch/holders returned 500 for that token.
- sniperSummary / creatorActivity skip swaps without a trader
- the sender lookup retries once before giving up
- healSwapTraders re-reads a bounded batch of null-trader swaps every poll
The suite decoded settle/take but never params[0], so a uint128
regression or a misplaced minHopPriceX36 could pass. Decode the v1 and
v2 ExactInputSingleParams tuples and assert poolKey, zeroForOne,
amountIn, amountOutMinimum — plus minHopPriceX36 === 0n for v2.
swap.ts claims unit-tested but only size/shape was covered: add
round-trip tests proving buy settles currency0/takes currency1 with
amountIn/minOut wired, sell flips, zero and uint128-scale amounts
survive, and v1 is the default. ago() rendered NaNd for unparseable
input (Math.max(0, NaN) is NaN): return 0s instead, with bucket, future-
clamp and garbage tests.
amountForUsd(Infinity, 1, 18) returned the literal string "Infinity",
which viem parseUnits rejects downstream in suggestFirstBuy. Guard the
usd input with Number.isFinite like quoteUsd already was, and cover
Infinity/NaN for both inputs.
With sessionStorage blocked or absent, the explicit "No first buy" was lost on a remount within
the tab because reads returned false. The store now keeps the latest value in memory and serves
it whenever storage cannot be read, so such a tab keeps the choice until reload. The server
snapshot stays false, so the hydration behaviour is unchanged. Tests cover true and false under
absent and blocked storage.
Once the default became selected without a wallet, a tab holding the "No first buy" flag rendered
the suggestion on the server and the cleared state on the client: a hydration mismatch. The flag
is now an external store (lib/launchpad/first-buy-session.ts) read with useSyncExternalStore: the
server snapshot is always false, so both renders match, and the client re-renders with the real
flag right after. No state is set in an effect. The form never touches sessionStorage directly.
Tests: the store's server/client snapshots, notifications and blocked-storage behaviour; the form
source test pins the hook and forbids direct storage access.
- One-string sites (trending strip, dashboard token lists, the mobile buy bar) rendered only the
main figure, so an unpriced quote showed "10 ETH" with nothing saying it is not dollars. The
display helper now also returns `compact`: the dollar figure when priced, else the quote figure
with the mark; those sites use it.
- The post-buy estimate in the launch form showed only the main figure; it now carries the detail
like "Opens at". The home row showed the detail only from md up; the mobile block has it too.
- A 6-decimal quote's detail rounded to whole units, so a 0.5 USDG cap read "1 USDG"; below 100
units it keeps two decimals now.
- Tests: helper cases for compact and fractional USDG; a source test pins every site to render the
detail or the compact form, and the form to have dropped the old quote-first formatter.
The list, strip, chart and dashboard already led with a dollar market cap, but the launch form
did not: an ETH launch picked "1 / 5 / 10 / 25 ETH" while every other quote picked "$5K / $10K /
$25K / $100K", the custom cap was in quote units, and "Opens at" led with the quote figure. One
rule now, in lib/launchpad/market-cap.ts: market cap is a dollar figure, the quote figure is the
detail, and a quote with no USD price shows its own figure marked "no USD price" instead of a dash.
Form: the same four dollar targets for every priced quote ($25K default, about the old 10 ETH), a
dollar custom input, dollars first on "Opens at", the buy preview and the preview card, with the
quote figure after. The tick is still computed from the quote-denominated cap, so the dollar
figure shown is the one the chosen tick produces (within one 2% tick step). Without a price the
form falls back to quote units as before. A preset pick no longer carries across quotes.
Rows, strip, dashboard and the mobile buy bar use the one display helper; the row's small line
shows the quote figure (or the "no USD price" mark). API unchanged. Tests for the entry mode,
presets, pick carry-over, the tick round-trip and every label.
The suggestion only appeared once a wallet was connected and its balance read, so the form opened
with "First buy · optional", empty, and "Connect a wallet to see the suggested amount". The default
should be visible immediately: 0.01 ETH selected, header "· suggested". A connected wallet can only
take it away: dropped when the balance cannot cover amount + gas (or the token balance the amount,
or the native balance the gas, for an ERC-20 quote) or when a balance read failed; kept, marked
provisional, while a read is still pending so it does not flicker. The rule still never blocks a
launch. The "no wallet" note is gone; the cleared note shows without a wallet too.
Clearing the field, toggling a preset off, or pressing "No first buy" all marked the suggestion
declined for the whole tab session, and the form then showed "· optional" with no explanation and
no way back except typing an amount. A creator who touched the field once while exploring saw no
suggestion afterwards and could not tell why.
Now: clearing the field or toggling a chip off declines in memory only; the explicit button is the
one action that persists for the session. Whenever the suggestion is declined and a wallet is
connected on the chain, the form says "No first buy. The launch stays free." with a "Suggest 0.01
ETH again" link. While the balance is still being read, or when the read failed, it says so
instead of silently showing "optional". Form source tests pin all three.
- A failed refresh just inside the 15-minute bound stored the stale value with a fresh attempt
timestamp, so the 60s memo could serve it for up to a minute past the bound. The memo now yields
whenever the cached value is past the bound; once the value is null the memo applies again.
- Concurrent callers each ran their own refresh, so a slow earlier attempt (Coinbase timing out,
then Chainlink) could land after a later one and overwrite a fresher Coinbase result with an
older timestamp. Refreshes now share one in-flight promise, the same pattern gitlawbServer uses,
which also stops the thundering herd when the TTL lapses under load. Tests for both.
PR 23 made a bad Coinbase response keep the last good price instead of blanking USD, which was
right, but with no upper bound a long outage left an old dollar figure on every page and every
dollar-based sort, silently. Order on each refresh is now: fresh Coinbase spot; else the Chainlink
ETH/USD feed on Base when its round is under 30 minutes old (it fails independently of Coinbase
and updates on a 0.15% move or every 20 minutes); else the last good value for at most 15 minutes
after it was confirmed; else null, which the UI already renders as ETH-only figures.
The feed address was verified on-chain before use: description() "ETH / USD", decimals() 8, and a
live round within a few dollars of Coinbase. The read reuses the stock feeds' aggregator ABI and
round-to-USD helper with a 30-minute max age instead of the equities' five days, under the same
5s timeout as the Coinbase call, and is injectable for tests like the fetch already was. One
warning is logged when the source switches and one when both are down past the bound.
Tests: the existing six pin Chainlink down so they still describe Coinbase alone; new cases cover
the feed conversion boundaries, Coinbase down with Chainlink up, an unusable Coinbase body with a
live or a stale feed round, the 14/15-minute bound with recovery from either source, and a cold
double outage. Display and sorting only: nothing on-chain reads these prices.
- A typed amount now remembers the chain and quote it was typed for and only applies while that
quote is selected, so "25" typed for USDG is dropped on a switch to ETH instead of becoming a
blocked 25 ETH buy, and "0.01" ETH does not become a 0.01 USDG dust buy. Done as derived state
rather than an effect (the lint rule rejects setState in effects). Clearing the suggestion still
survives a quote switch: that intent is about the launch, not the asset.
- amountForUsd could return "0" when a quote has fewer decimals than the amount needs, which the
rule then presented as a suggestion while the form ran no buy. It returns null now. No quote on
either chain has that few decimals today; tests cover it anyway.
The launch transaction is sent first and pays its own gas before the buy runs, so a reserve that
covered only the buy could let both an ETH and an ERC-20 first buy pass validation and then fail
after launch. The reserve is now the sum of a launch part (0.001 ETH: deploy, pool init, position
mint) and a buy part (0.0005 ETH: approvals and swap), used by the suggestion rule and by both
typed-amount checks. Boundary tests for both quote types at exactly the reserve and one wei under.
The suggestion for a USDG, GITLAWB or stock quote checked only the token balance, so a wallet
holding tokens but no ETH could be handed a pre-filled buy that fails after the launch (the
approval and the swap pay gas in ETH). The native balance is now read whenever a wallet is
connected, the rule takes it as a separate input, and an ERC-20 suggestion needs both the token
balance for the amount and the native balance for the gas reserve; a missing native read means no
suggestion, as with any unknown balance. A typed ERC-20 amount gets the same gas check as a typed
ETH amount. Tests cover the "tokens but no gas" and "native balance unknown" cases.
ethUsd overwrote its 60s memo with null whenever Coinbase answered
non-200 or an unparsable body: one 429 blanked every USD figure on the
site (layout, home, token pages, list/live/search/me APIs, GITLAWB USD)
for a full TTL. Check res.ok, parse defensively, keep the last good
price on any failure, and bound the fetch with a 5s timeout. Failures
still advance the memo timestamp so a down upstream is retried at most
once per TTL.
Verified: npm run lint, tsc --noEmit, npm test (296 pass), npm run build.
Route and global error boundaries declared a retry prop that Next.js
never provides, so Try again was wired to undefined and did nothing.
Use the real reset prop, render the status as an h1 inside a labelled
landmark, announce the failure with role=alert, and display error.digest
only when it is a short opaque id.
The 404 copy claimed every bad URL was an unlaunched token; make it
generic and offer both home and the launch flow.
Verified: npm run lint, tsc --noEmit, npm test (295 pass), npm run build.
Of 380 launches on the production copy, 18 used the first buy at launch and 320 never bought at
all; tokens whose creator bought went on to get an outside wallet twice as often (18/60 vs 48/320).
The feature existed and nobody found it.
The form now pre-fills the smallest preset (0.01 ETH, 25 USDG, 500k GITLAWB) or $25 worth of a
stock quote, and says why: a token with no holders and no price move reads as dead on screeners
and sits under quiet launches on the home page.
One rule, in lib/launchpad/first-buy.ts: a suggested buy never blocks a launch. It exists only
when a wallet is connected on the right chain, the balance is known and covers the amount plus the
gas reserve, and the creator has not cleared it; otherwise the launch is free, as before. A typed
amount keeps the strict checks. The balance is read as soon as a wallet connects (it used to wait
for a typed amount). "No first buy" clears it, and clearing is remembered for the session.
Copy: "First buy · suggested", "Launch + first buy", the why line, a note when the wallet holds
only gas. The honesty lines stay (others can buy before you, slippage, gas; no "first holder").
Docs: llms.txt, agents page and the rules FAQ recommend a buy right after launch.
Tests: the suggestion rule (presets, $25 stock math, every no-suggestion reason), form source
assertions updated. The connected-wallet states need a browser with a wallet; the server-rendered
form was checked on both chains.
- live route: when the list cannot supply the strip's candidates (non-default sort, chain or filter),
the trending query now runs inside the same Promise.all instead of after it.
- Wording, no rule change: the sniper window excludes every swap in launch block + 3, not only buys.
All supply starts in the pool, so a sell in that window can only be a sniper flipping what it just
bought; counting it would re-admit exactly the bot the window exists for. And the grace hour goes
to a wallet's newest launch, not every launch: one wallet made 103 of the 200 Base launches, so an
uncapped grace hour is a way to fill the board. The rules page, llms.txt and the code comments now
say both; the rules FAQ also says "trade from an outside wallet" since sells count.
Fixes from the branch review, none of which change the rule:
- The list's own fetch reused the URL params, which omit the default sort, and the API's default is
"new": clicking Live or clearing filters loaded the newest-first list and drew chips over it. The
request now always carries the sort.
- "trending" was still accepted from the URL but the list only knew "live", so a bookmarked
/?sort=trending rendered the collapsed list with no tab, chips or divider. One parseSort() now
allow-lists and aliases trending -> live for the page and both routes; the LaunchSort union drops it.
- Ordering and the chip used bb_launches.last_trade_at, which the launcher's own swaps and sniper
buys also move. The stats probe now returns last_outside_trade_at (the same outside-wallet
predicate) and the live sort orders by outside wallets this hour, then today, then that time; the
other tiers by age. Holders left the order.
- The client recomputed the tier from its own clock, so the divider and ranks could disagree with the
database order (hydration at the 1h boundary, duplicate dividers while the order was held). The
SQL tier now travels with the row as live_tier and the client reads it; one divider, at the first
quiet row.
- Home render and the live poll ran the identical live query twice (list + strip). The default
view's first page is the strip's candidate set, so trendingFrom(page.items) replaces getTrending
there; getTrending stays for other views.
- One LATERAL probe per launch instead of two: the hour's figures are FILTERs over the day window.
The unused traders_24h column is gone; GRACE_HOURS no longer sizes the 1h stats window.
- Strip tooltip describes the new weighting; docs mention the new fields and the "then today" step.
The home list defaulted to launch order, so the first screen was whatever was launched last:
duplicates from two wallets and zero-holder rows. Replace the Trending tab with Live and make it
the default. One rule, in lib/launchpad/ranking.ts and mirrored in SQL so paging stays correct:
A token is live when an outside wallet traded it in the last 24h. Outside = not the launcher and
not a buy inside the sniper window (launch block + 3, the holders panel's definition; one bot
sniped 121 launches within 2s and would otherwise have made every token live). Live tokens rank
by outside wallets this hour, then last trade, then holders. Then launches under 1h old, then
quiet launches under a divider, one row per launcher (the rest are folded into it).
- queries: per-row `traders_1h_ex` / `traders_24h_ex` (outside wallets) in both windows, a `live`
sort built as a CTE with the tier and a per-launcher row_number, `launcher_collapsed` on the kept
row; `trending` stays accepted and orders like `live`. Nothing hidden: New, search and the API
keep every launch. No schema change.
- ranking.ts absorbs trending.ts: tier + chip helpers for the list, strip score now weighs distinct
outside wallets first (trades log-scaled), day window needs an outside wallet.
- list UI: Live tab first and default, rank numbers only for live rows, a chip per row saying why it
sits there, a "Quiet launches" divider; the URL writes sort=new explicitly now.
- docs: llms.txt, agents page, rules FAQ ("Why is my token under quiet launches?"); smoke checks the
live sort and its fields.
Verified against a local production copy: 51 live / 103 quiet rows at a pinned clock, one row per
launcher outside live, no overlap between pages, no self-traded token counted as live.
Next 16.3 hands error boundaries both `reset` and `retry`; `retry` refreshes
the router inside a transition and then resets, so server-side data that
failed is fetched again, while `reset` only re-renders the same tree. Both
boundaries now bind "Try again" to `retry`, matching the current Next docs.
The previous commit cleared the picked stock whenever a chain button was
clicked, including the chain already selected, so a user could lose their
stock by tapping the highlighted chain. The handler now returns early for the
active chain and only resets state on an actual switch. Verified in headless
Chrome: pick AAPLc on Base, click Base again, the chip stays; switch to
Robinhood Chain and it is gone.
The chain buttons reset the quote key and market cap but kept `stock`, so a
Coinbase stock picked on Base survived a switch to Robinhood Chain. Selecting
Stock again then showed that Base registry address as the quote, labelled as a
Robinhood stock, and it would have been handed to the Robinhood factory.
Reproduced in headless Chrome before the fix; after it, the chip is gone and
the Robinhood registry has to be searched. The launch-form test pins the reset.
- "Pick a stock to price the token in, or switch the quote." is one constant,
shown by the validation list and the status box above the submit button; the
third copy (a warm chip beside the search field) is gone.
- The picked-stock chip reads "AAPLc · Coinbase stock · Apple" instead of
"Quote = AAPLc (registry)": the issuer is what a buyer needs to know.
- aria-live came off the whole stock section, where it re-announced the
thirteen search results on every keystroke; the status box (role=status) is
the only live region.
- data-testid hooks removed: the repo tests source contracts, and the new
launch-form test pins the single message, the issuer label, "Switch quote"
resetting to the first configured quote, and the shared disclaimer helper.
Dig #2 (Grok Super B-20 stock UX): sticky « Quote = SYMBOL (registry) »
chip beside the Stock picker, Reg S / issuer disclaimer adjacent to the
pick, and CTA fail-closed with an explicit chip when Stock is selected
with no registry pick. Registry-gated via existing /api/quotes only.
Deferred: dig #3 above-fold Stock for Instant (Instant Advanced path
ships separately on feat/ol-stock-ux).
- manifest.ts: "any" only describes SVG icons; list icon.svg as the scalable
icon and give icon.png its real 512x512 size next to the 180x180 apple icon
- global-error.tsx: log from useEffect like error.tsx does, instead of during
render
- error.tsx / global-error.tsx: Next passes the boundary `reset`, not
`retry`; the "Try again" button was wired to undefined
- sitemap.ts: force-dynamic so the token list is not frozen at build time
(CI builds without DATABASE_URL, which left only the static routes)
- split a jammed import line in the token page and restore the newline
before tokenJsonLd
- error.tsx + global-error.tsx: use the stable retry() callback
(Next 16.3) instead of reset() so recovery re-fetches RSC data.
- global-error.tsx: load ./globals.css and the shared font variables
(new app/fonts.ts, also used by the root layout) since the fallback
replaces the root layout and inherits neither.
- robots.ts: disallow /admin and /api (slashless) so the exact paths
are covered, not just their children.
- sitemap.ts: WHERE chain_id IN (8453, 4663) so unknown chains can
never be mislabelled as Base.
- JSON-LD: new jsonLdHtml() helper escapes < to \u003c so a
creator-supplied name/description cannot terminate the script
element; token page uses it, covered by a round-trip unit test.
Add crawler discoverability the site was missing: /robots.txt
(allow /, disallow /admin + /api, sitemap ref), /sitemap.xml
(static routes + newest 1000 launches, fail-soft when the DB is
unconfigured), and /manifest.webmanifest (existing app icons).
Add route error boundaries (error.tsx + global-error.tsx) mirroring
not-found.tsx so a render failure shows a reset action instead of
Next's default digest page.
Token pages get a canonical URL and facts-only JSON-LD (on-chain
fields + creator metadata, no scores or flags) for rich results.
Pure helpers live in src/lib/seo.ts with node --test coverage.
hasInjectedProvider was read through useSyncExternalStore with a no-op
subscription, so a wallet that set window.ethereum after the picker mounted
never produced the "Browser wallet" row until the sheet was reopened. Subscribe
to the EIP-1193 "ethereum#initialized" event and to EIP-6963 announcements and
re-read the flag on either. Verified in headless Chrome: with the sheet open and
no wallet, injecting window.ethereum adds the row in place; a late EIP-6963
announce then replaces it with the announced wallet. The source-contract test
pins the subscription and the two event names.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_0146kzimBSuHTErfKYhMCaic
Every "Connect wallet" button looked for a connector with id "coinbaseWallet",
but wagmi's Coinbase connector registers as "coinbaseWalletSDK". The lookup
never matched, so the app always fell through to the generic injected
connector. Coinbase Smart Wallet could not be chosen, and in a browser with
no wallet extension the click failed with ProviderNotFound and no UI reacted:
a dead button. That also hid that @coinbase/wallet-sdk (an optional peer of
wagmi) was not installed; the production bundle carried a "Cannot find
module" stub in its place.
- lib/wallet-connectors.ts: pure module choosing which wallets to offer
(browser-announced EIP-6963 wallets first, the generic browser wallet only
when something is injected without announcing itself, Coinbase always) and
turning connect failures into plain text without echoing raw errors.
- components/WalletPicker.tsx: the connect dialog on the existing Sheet, one
in-flight request at a time, errors shown inline with role=alert, closes on
success. components/ConnectWallet.tsx: a button in the caller's styling
that opens it.
- Header, trade panel, launch form, posts composer and the /me dashboard all
connect through the picker; no component calls useConnect any more except
the picker, and a test pins that.
- @coinbase/wallet-sdk ^4.3.7 installed (registry tarball, integrity matches,
no eval or script/iframe injection; its origins were already in the CSP).
Verified in headless Chrome against the dev server: no wallet shows Coinbase
plus guidance and the Coinbase row waits on its popup; a fake EIP-6963 wallet
is listed, connects, and the header shows the address chip; a bare
window.ethereum is offered as "Browser wallet". Lint, typecheck, 279 tests
and a production build pass.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_0146kzimBSuHTErfKYhMCaic
The /feed loader still mirrored the old single-column feed (48rem wide,
ten 32px rows), so navigating to Posts showed a narrow, short placeholder
that jumped into the wide two-column layout when posts streamed in.
- feed/loading.tsx now borrows the SectionShell and CommunityFeed CSS
modules: intro (eyebrow, title, description, pill action), then the
panel (toolbar, filters, result line, posts, foot) beside the guide and
note aside, so breakpoints stay in sync with the real markup
- new SkFeedPost primitive matches one community post: 40px wallet
avatar + author heading, 26px-pitch body lines, 20px token tile line,
"Open conversation" line, 24px padding (20/16 on phones)
- SkPost gains `i` and `avatar` ("round" post / "tile" launch-tape row),
a 28px avatar and a clamped-body shape; the home sidebar skeleton uses
56px widget headers and COMPACT_LIMIT rows
- feed-loading.test.ts pins the shell/region usage, region order, post
anatomy and the home sidebar shapes so the loader cannot drift again
Measured against the live page in headless Chrome at 1440px and 500px:
every region lands within 1-2px of the real one.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBa4QU3W9Ak65FwHSUs6hR