32 Commits
Author SHA1 Message Date
Kevin Codex 12ba7c3f63 feat(points): tag in-app swaps and give each season a rules version
- bb_launch_swaps.via_app: true when the swap's transaction carries the app's
  ERC-8021 builder suffix. That means a wallet's own Universal Router call, or
  the smart-wallet operation the swap is credited to. Set on insert, and again
  when attribution or the trader heal re-credits a swap. NULL means unknown,
  which never counts. Only the tag result is cached, never the input.
  applyLaunchTx reads the transaction for its input; if that read fails, the
  swap is still credited from the receipt, with the tag unknown.
- Checked on real Base trades from the public feed: router trades from the app
  are tagged; smart-wallet trades routed through 0x's AllowanceHolder (another
  front end) are not.
- bb_seasons.rules_version: a season keeps the version it started with
  (score.rulesFor), and new seasons start on LATEST_RULES_VERSION. Every
  existing season is version 1. scoreSeason takes its rules as an argument. The
  admin panel and /api/points show the version.
2026-11-11 10:03:31 +08:00
Kevin CodexandClaude Opus 5.5 3068880e53 feat(usd): price every trade and fee at the time it happened
The all-time volume (and the fee and per-launch USD figures) priced every trade
ever made at TODAY's quote price: queries.getLaunchTotals summed each quote's
volume times its live price. So September trades moved with today's ETH and
GITLAWB. Both sit near their lows since launch (ETH $2,063..$2,784, GITLAWB
6.2e-6..7.5e-5), so the figure was understated. This change records each trade's
USD value at its own hour. The site still reads the old figures until a second
change switches it, after the backfill below.

- bb_quote_usd_hourly: one USD price per quote per UTC hour. The indexer writes
  the live price into the hour in progress (usdLedger.captureLiveUsd: at most
  once a minute per chain, always on a new hour, same sources as the lists via
  queries.quoteUsdNow; never throws).
- bb_launch_swaps.usd / bb_launch_fee_events.usd: each trade's and fee's value
  at its own hour, written in the event's own transaction together with new
  running sums bb_launches.volume_usd / fees_collected_usd / fees_burned_usd. No
  price for that hour means NULL (never a guess), left for the backfill. Fees are
  quote side only, as the USD fee totals always were.
- scripts/usd-backfill.ts writes ONE reviewed SQL file and touches no database:
  - hourly prices from launch day: ETH-USD and HYPE-USD from Coinbase (HYPE gaps
    from Hyperliquid); GITLAWB from its GITLAWB/WETH pool via GeckoTerminal (price
    in WETH, forward-filled between trades) times ETH-USD, the live method; TWIG =
    GITLAWB; stables $1
  - --mode dry-run prints old method vs trade-time per chain and quote, totals and
    fees, then ROLLS BACK
  - --mode apply prices rows with no USD value, then recomputes launch totals
    under the rebuild-launch-totals.sql locks
  - It never replaces an hour captured live, and stops at the last completed hour
    (a partial candle would block the live capture for the rest of the hour).
- LAUNCH.md: the runbook.

Points scoring is unchanged this season. Stocks, community pairs and unlisted
quotes have no price history: their older trades stay without a USD value.

Verified on a throwaway Postgres 16:
- the real migrate.mjs applies on a fresh database and again unchanged
- a fixture of ETH, Arc USDC, GITLAWB and unlisted-quote launches with trades
  and fees: the dry run reported the hand-computed values and left the database
  untouched; apply wrote exactly those values, kept the live-captured hour,
  left the unlisted trade and the token-side burn NULL, and a second apply
  changed nothing
- the real captureLiveUsd wrote live rows through the real driver; a live
  2-ETH trade priced at 2 x the captured spot and the launch total moved by
  exactly that
The backfilled GITLAWB and ETH for the latest hour match the live site's within
1%. tsc and lint clean; npm test 1404 (1393 pass, 11 skipped, 0 fail).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 23:01:11 +08:00
Kevin Codex 485b0e2656 fix(pairs): anchor the spent-quote backfill to the migration time
A burn recorded before any quote, and valued only after one was issued, still
clears the quote, so the latest burn time could fall before the quote's real
end. Every quote that exists when the migration runs began before it, so it
ends by then + 30 minutes: applications a waiver could still apply to (open,
changes) with a burn and no recorded end now wait that long.
2026-10-17 17:08:50 +08:00
Kevin Codex c5be4f9467 fix(pairs): backfill last_quote_until for quotes a burn already spent
The backfill only copied live quotes, so an application whose quote a partial
burn had cleared before the column existed got no hold. Those now get the
latest burn + 30 minutes: a quote ends at most 30 minutes after any transfer
sent under it, so that is never before its real end. The column and both
backfills move below bb_pair_burns, so a fresh database still migrates.
2026-10-17 16:59:54 +08:00
Kevin Codex 1445e05d39 fix(pairs): a waiver also waits for a second transfer under a spent quote
A landed burn clears the quote (quote_until included), so with two transfers
under one quote the waiver opened while the second was still indexing. Every
quote now also records its end in last_quote_until, which a burn never
clears; settling_until, the waiver check and the claim use the later of the
two. The migration adds the column and backfills it from any live quote.
2026-10-17 16:52:02 +08:00
Kevin Codex 8dd2cd6445 feat(admin): sign in to /admin with a wallet; every admin route needs the session
/admin rendered every panel for anyone, and two admin reads (reported
posts with hidden post text, Boost stats) were gated only by
?wallet=<admin address>, which anyone can pass.

- Sign-In with Ethereum (EIP-4361): a standard message for openlaunch.lol
  on Base, so wallets that support it warn when another site asks for it.
  The server issues the nonce (single use, 5 minutes, with its own time)
  and accepts only the exact text it would build. The nonce must be open
  before the signature check and is spent only after it passes.
- Session: a random 32-byte token in an HttpOnly, Secure, SameSite=Strict
  __Host-ol_admin cookie, 8 hours, at most 5 per wallet. The database
  keeps only its SHA-256 and a "Chrome · macOS" label (no IP, no user
  agent). Each request re-checks ADMIN_WALLETS. At boot, which a secret
  change triggers, sessions of wallets no longer listed end for good.
- Without a session /admin is a sign-in card only. Panels act only from
  the signed-in wallet. Sessions list, end one, sign out everywhere.
- Every admin route needs the session; ?wallet= is gone. Admin actions
  keep their own wallet signature, from the same wallet and the site's
  own Origin. The profile queue and season boards load from the session
  (no signature popups). A creator muting their own token is unchanged.
- /admin is private, no-store. docs/admin.md describes it all.
2026-10-17 10:02:04 +08:00
Kevin Codex 4468f07d9a Merge feat/community-pairs (pairs never in the launch form) into feat/pair-applications
- writeListing, applications and tests without in_form
- the apply page says what a listing gives and that the launch form only
  offers ETH, TWIG and stocks, before anyone pays
- docs/pairs.md, llms.txt and /pairs say the same
2026-10-16 21:49:37 +08:00
Kevin Codex 3dbea7a823 feat(pairs): community pairs never in the launch form
Kevin's call: pairs are listed, priced and badged, but the launch form
offers ETH, TWIG and stocks only, as MUSEWORLD's rule always was.

- LaunchForm and /launch back to main (no Community option, no ?quote=)
- no "Launch with X" on /pairs or the pair page; both say how pair
  launches are made (against the factory, by the community's own tools)
- in_form removed from bb_pairs, the listing, the signed admin message
  and the admin panel
- test: the form, the launch page and the pair pages never offer a pair
2026-10-16 21:47:13 +08:00
Kevin Codex fbfd4ef696 fix(pairs): review pass on applications
- unpaid applications expire after 48 h (never under a live quote, never
  once anything was burned); "changes" past the 60-day window expire; an
  admin can close any application in progress: nothing holds a token's
  place for good
- the fee step never offers to send again while a sent transfer is not
  indexed (kept across a reload), with a Check again that only re-syncs
- burns: the raw fact first, then its value, then the total, each step
  idempotent; unvalued burns are valued later from the sync loop; a landed
  burn spends its lock, so the next quote is for what is still due
- a lock records when it began: a burn sent before it is not at its price
- decisions are claimed before a listing runs; a failed listing gives the
  review back
- the per-wallet cap and the insert run under one advisory lock
- asking for another review stores a fresh check
2026-10-16 21:16:54 +08:00
Kevin Codex 20a4cc5bd6 feat(pairs): self-serve applications, $200 of TWIG burned for a review
Behind PAIRS_APPLY=on. A community checks its token for free, applies
with a verified-X profile, posts a proof code from its own X account,
pays the fee and gets a human review.

- pairCheck.ts: the free pre-check. Hard: ERC-20 6-18 decimals, own
  symbol, not a pair or core quote, not an upgradeable proxy, a price pool
  against a core quote that reads a price with >= $50K, >= 14 days,
  >= 500 holders, and a simulated transfer from a real holder (state
  override, TransferProbe.sol) that arrives whole and reaches 0x…dEaD.
  Flags: owner(), clones, DELEGATECALL, B20 roles, top-10, volume, GoPlus.
  GeckoTerminal answers cached 10 min per token; unknown is never a pass.
- pairApply.ts: kind-3 tag (3x10^11 + application id), fee quote at the
  lower of TWIG spot and 30-min average rounded up to 100K, 30-min lock,
  burns add up (quote price inside the lock, live after), paid within 2%,
  3 reviews within 60 days, proof post text and link rules, messages
- pairApplyServer.ts: apply / proof / again (signed by the applicant),
  quote (a running lock is never replaced), burns from the indexer, admin
  review (approve lists through writeListing's checks)
- indexer: TWIG→dEaD logs that are not boosts are tried as pair fees, in
  the range scan and the receipt path
- /pairs/apply, /pairs/apply/<id>, /admin review queue, /pairs CTA
- gitlawbServer: spot and average exposed for the fee
- docs/pairs.md, llms.txt
2026-10-16 21:07:48 +08:00
Kevin Codex 47f7f062f1 fix(pairs): review pass
- monitor: writes only if the row is unchanged since it was claimed, so an
  admin's suspend or delist is never written over
- reloadPairs starts a new read after a write (numbered reads: an older
  read never replaces a newer one)
- a pair listed without a code hash (the seeded MUSEWORLD) is pinned on its
  first check, so a code change suspends it too
- pool health reads the pool's own spot and average: an ETH or GITLAWB
  price outage no longer counts against every pair priced in it
- listing refuses a pool under the monitor's $25K line
- one listed symbol per chain, enforced by a partial unique index
- over-long fields are refused, never cut (the signed text must match)
- one list of core quotes (config.coreQuotes) and core names
  (unlisted-quote CORE_QUOTE_*), shared by server, admin and rules
2026-10-16 20:49:15 +08:00
Kevin Codex c88377ac76 feat(pairs): community pairs list, MUSEWORLD as its first pair
An app-level list of community tokens that launches may be paired with.
The contracts are untouched and still accept any ERC-20; a token missing
from the list shows as an unlisted pair, as before.

- bb_pairs / bb_pair_events / bb_pair_prices; MUSEWORLD seeded as pair #1
  (launch-priced from its own GITLAWB pool, as before)
- pairs.ts: rules, price math, the 30-minute guard (an average is required),
  the monitor's status machine, the admin message, copy
- pairPrice.ts: launch / Uniswap v3 (+ Slipstream) / Uniswap v4 price legs;
  v4 keys read from the PositionManager, hooks must be named by the admin
- pairsServer.ts: stale-while-revalidate list, signed admin actions
  (list, update, suspend, delist, reinstate), hourly monitor that claims
  each pair on one machine and suspends on a code change or 24 h of
  failing checks; v4 minute samples
- quote key "museworld" → "pair": badge with the community's logo, no seal,
  no "official" anywhere; SQL USD sorts and decimals arms per pair;
  suspended pairs resolve as unlisted (out of Trending); points count only
  pairs priced from an openlaunch pool
- /pairs, /pair/<chain>/<address>, GET /api/pairs, POST /api/pairs/mod,
  /admin panel, launch-form Community picker (?quote= preselects),
  trade presets in USD, share card pill, llms.txt, /rules, sitemap
2026-10-16 20:40:43 +08:00
Kevin Codex fadf8801e7 fix(boost): count a lane click once per address, not per user agent
The pre-deploy review of PR 99 found that one address could pump a card's public click count: the visitor key mixed
in the user agent, which the caller picks, so a loop with a random user agent counted on every request (about 60 a
minute per machine). A click's visitor is now the address alone (an IPv6 address by its /64, an IPv4-mapped one by
its IPv4 address) with the daily salt, and the per-IP bucket uses the same key. One address adds at most one click
per card every 30 minutes; people behind one shared address count once, so the count leans low rather than high.

Also: a poll from a machine still on the older build (mid-deploy) has no `clicks`; cards show none instead of an
"undefined clicks" tooltip.
2026-10-16 13:41:28 +08:00
Kevin Codex 69ae71c54e feat(boost): click count on each lane card; compact rows on phones
- Each Boosted card shows its clicks from the lane in the last 24 hours (the window its TWIG is counted in) with a
  pointer icon: nothing at 0, rolls up when a poll brings more, a screen-reader label, and the count in the card's
  tooltip. A pointer, not an eye: one page load is a view for every card, so views would read the same on all five.
- The lane read carries `clicks` (one indexed query per lane build; a failed read shows no counts, never no lane).
- Under 640px a card is a compact two-line row (name left; TWIG, clicks, time left right): all five places fit on a
  phone screen, about 62px a card instead of 110px. "Latest burn" from 640px (the token page has it on a phone).
  One set of parts placed by grid areas, so the stacked card from 640px is unchanged.
2026-10-16 13:00:29 +08:00
Kevin Codex 113289a7f1 feat(boost): whole-card lane links, hover, and views/clicks while boosted
- The whole Boosted card opens the token (stretched link, LaunchCard's pattern); "Latest burn" stays its own link
  above it. Keyboard focus rings the whole card.
- Hover: warm tint, a still warm edge, an arrow, a slightly larger avatar, a brighter fuse. Only under a pointer
  that hovers; reduced motion keeps the colours. Nothing loops.
- POST /api/boost/seen (sendBeacon): one view per page when the lane is on screen, and each card click. A click
  counts only while the token is in the lane, at the place the server's lane gives it, once per visitor, token and
  30 minutes. Bots skipped, 60/min per IP, 512-byte body. The visitor is the daily presence hash mixed with the
  token: no IP, user agent or wallet stored.
- bb_boost_views (hourly counts) and bb_boost_clicks; /admin gets a "Views and clicks" table (24h/7d/30d) via
  GET /api/boost/stats for admin wallets. The ranking never reads either table (guard extended).
2026-10-16 12:50:01 +08:00
Kevin Codex 103bcb93ba feat(boost): TWIG sent to 0x…dEaD buys a place in a labelled Boosted lane
A boost is one plain TWIG.transfer(0x…dEaD, amount) on Base whose amount
names the launch in its last 12 digits (whole TWIG x 10^18 + the launch's
position id). The tag lives in the Transfer event, so the indexer reads logs
only and plain wallets, smart wallets, Safes and agents all boost the same way.
No new contract, no approval, nothing paid to openlaunch or Twigpine.

- lib/launchpad/boost.ts: the rule (floor 100K TWIG, 24h window, lane of 5),
  amount encode/decode, Max under a balance, Transfer decoder, lane order,
  time left, moderation message, and every sentence the feature shows
- indexer: one filtered log query per Base range plus the receipt path;
  bb_boosts holds raw facts, the locker's own fee burns are skipped
- boostServer + GET /api/boost, POST /api/boost/mod (admin-signed hide/unhide)
- the lane rides on the live poll; home page and /base under the Trending
  board; token page panel and dialog; /me link; ⌘K entry; admin section;
  rules FAQ entry; llms.txt section; hero "TWIG burned" adds boost burns
- dialog: lock before the first await, Base switch, simulate, builder code;
  sent means sent (an unread receipt locks it with "Check again")
- the Live sort, Trending and ranking.ts never read boosts (guard test)
- ships dark: everything is off unless BOOST=on

Verified on an anvil Base fork with real transfers: the receipt and range
paths record exactly the tagged boost and skip under-floor, untagged and
locker burns; migration is idempotent on a dev copy and an empty database.
2026-10-15 09:11:13 +08:00
Kevin Codex 7ca4371a4b fix(points): review round 2 (net buyers only for fee points, publish once, public season reads)
- fee points (both boards) count only on BUYS by traders who still hold
  half of what they bought of that token this season: a round trip,
  whose fees go back to a farmer's own token, earns nothing, so washing
  ties up capital that grows with volume; scout fee points also need a
  token with 5+ eligible real holders
- a holder is placed on their best token among the ones that count
  (best 3 per launch day), so a cut token no longer loses its holders
- publish: only the FIRST publish starts the clock and drops the shadow
  points (published_at); showing the boards again after hiding, or an
  ended season, changes neither
- the price abort is limited to ETH / GITLAWB / TWIG; any other unpriced
  quote only leaves its own tokens out of that run
- final standings = a compute whose data cut-off (computed_until) is at
  or past the end; starting a season refuses (409) until the previous
  one is final
- public pages and APIs read the latest PUBLIC season, so an ended
  season keeps showing its final standings during the next shadow run
- /api/points never says whether a moderator hid or kept off a wallet
- schema: ALTER ... ADD COLUMN IF NOT EXISTS for computed_at,
  computed_until, published_at; copy for ended seasons; rules wording
  says "qualifying profile" and spells out net buying
2026-10-09 13:07:16 +08:00
Kevin Codex 0dfc8cdf74 fix(points): review round 1 (real buyers and holders, priced tokens only, fee and hold caps)
The review showed the first rules could be farmed for gas: an unpriced
quote skipped every dollar floor, 1-wei transfers counted as holders and
unlocked every threshold, wash trades earned fee points while the fees
came back to the farmer, "still held" meant any balance, and a price
outage could replace the board. The scoring now rests on two definitions:

- real buyer: first buy >= $5, outside the sniper window (launch block +
  3 and the first 12 seconds, so fast chains are covered), not the
  launcher / a recipient / a transfer-fed wallet / a flagged wallet
- real holder: a real buyer still holding >= $5 (>= $1 if eligible)

and on top of them:
- only priced tokens score; a run with a listed quote unpriced (or no
  ETH price) writes nothing and the last board stays
- creator fee points only from eligible traders, at most 50 per trader
  per creator per day; trades under $5 never count
- each holder counts once per creator (their best token)
- "still held" = at least half of that buy; scout fee points at most
  200 a day across all tokens; early slots only for real buyers, ordered
  by block and log index
- dump = sold during the season and now keeps under half of what was
  bought (selling fee tokens no longer counts); "alive" removed
- flags apply to deleted profiles too; thresholds are recomputed every
  run (no ratchet table)

Server: reads run one after another on the reserved lock connection, the
replace on the pool, and the loop no longer waits for a compute.
bb_seasons.computed_at throttles (a season with no points no longer
recomputes every few minutes); an ended season gets one final compute
and admin recompute refuses after it; starting a season finalizes the
previous one first; publishing starts the season's clock for everyone.
Eligibility thresholds live in RULES (the rules page is static). /me
says what stands between a wallet and the board; ended seasons show
final standings; the profile page fails soft on points errors.
2026-10-09 13:07:16 +08:00
Kevin Codex 2986f8bf99 feat(points): Season 1 points, leaderboard, shadow run
Reputation points for launching and trading, recomputed from scratch from
the chain index every hour (lib/points/score.ts, pure and tested). They
reward what costs real money or reputation to fake:
- creators: outside buyers who bought this season and still hold a day
  later (30 each when the buyer's profile is points-eligible, 5 if not;
  half again at day 7), 10 per $1 of fees from outside trades, 100 for a
  token alive at day 7; a token scores 0 if its creator sold half of what
  they bought this season; best 3 tokens per launch day. Any token
  counts, only season activity.
- scouts: first 25 outside buyers of a token that reaches 50 outside
  holders (100, +50 while holding), buys held a day (5, 20 a day), fees
  paid on other people's real tokens (10 per $1, capped per token/day).
- never: launches by themselves, own-token trades, trades under $5,
  sniper-window buys, launcher / recipients / transfer-fed wallets,
  wallets kept off points.

Eligibility (leaderboard + the 6x holder weight) = X-verified profile on
an account POINTS_MIN_X_AGE_DAYS (30) old with POINTS_MIN_X_FOLLOWERS
(20). Unverified wallets see the points waiting for them on /me.

An admin starts a season from /admin (not public: the shadow run), sees
the top 50 of each board eligible or not with why lines, publishes when
the numbers look right, recomputes or ends it. /leaderboard shows a
teaser until then. The rules are on /rules#points with the "no cash
value, not a token" line. One machine computes (session advisory lock);
writes go through the pool in one replace transaction.
2026-10-09 13:07:16 +08:00
Kevin Codex 658b50db9f fix(profiles): ultra review + CodeRabbit on 6c88cef
Signing and text
- Cleaning is idempotent: one pass could NFC-compose across a character it
  removed, so a name like "Jose<ZWSP><acute>" cleaned differently twice and
  the server's rebuilt message never matched the signature (401 forever).
  It now repeats until nothing changes, keeps the emoji joiner and
  presentation selectors only inside emoji (and tags only inside a
  subdivision flag), and cuts at whole graphemes, never half an emoji.
- The save signature is checked over the fields exactly as sent, or else
  over their cleaned form; the stored values are always the cleaned ones.
- Smart-wallet code checks cache only "has code on this chain" (permanent)
  and never "no code": a cached "deployed nowhere" (seedable with a junk
  signature) was the window for an old owner to sign through ERC-6492, and
  a partial list sent multi-chain wallets to the wrong chain.

Deploy safety
- The three new bb_launch_swaps indexes move to db/concurrent-indexes.sql,
  built by migrate.mjs after the schema transaction commits, with CREATE
  INDEX CONCURRENTLY (an invalid leftover is dropped and rebuilt). Inside the
  transaction they held the exclusive lock an existing ALTER on that table
  already takes, so every read and write on swaps queued behind the builds.

Profiles and moderation
- A hidden (not deleted) profile can't be saved: its owner sees "Create
  profile" and the blank form would overwrite the real row and drop X.
- Approving a claim releases any other wallet still holding the account's
  binding (was a 23505 → 502 on every retry); verifying does the same and
  now also clears their account facts and open codes.
- Usernames can't start with 0x (they read as short addresses), and digit /
  underscore look-alikes of reserved names are reserved ("0penlaunch_team").
- The availability check knows a verified owner may claim their own handle;
  that claim now requires keeping the verification in the same save.
- Coming back after a delete under the name the delete let go of is no
  rename (no clock, the clock is not restarted).
- Verification posts never @-mention the username (it is not an X handle).
- X down + a link from another account: an honest error, nothing queued.
- Re-check: only X saying "gone" pauses; X's sources disagreeing, or a new
  handle with no account id (a rename), is unsure, and unsure reads are
  retried in about an hour instead of after the full 1 or 7 days.
- Only a duplicate nonce is a replay; other DB errors are errors.

Indexer
- applyLaunchTx gives attribution the receipt it already holds.
- attributeBacklog's start block comes off the block_time index (the old
  min() walked 597K rows / 99 ms per poll per chain on a 600K-row table;
  now 4 buffers), an empty pass rests 5 minutes, and it reads 5x the batch
  so rows waiting out their retry gap no longer block the rows behind.
- healSwapTraders reads each transaction once, and rests unreadable ones.

Client
- Saved and verified names go straight into the names store (marked as
  this browser's own, so no older page render overwrites them); lookups
  time out after 10 s; the cache size check runs before answers land.
- The token page seeds no names when the lookup failed; /u seeds the owner
  from the profile it read.
- The /me card retries a failed lookup; "Being checked" is a status, not a
  button into a code the server will not take; a spent code is cleared.
- Wallet trade rows are keyed by chain, tx and log index.
2026-10-09 08:42:17 +08:00
Kevin Codex 8860645764 fix(profiles): CodeRabbit full review 3 + independent review (zero-address signatures, claim-bound moderation, paymaster ops)
Security
- Nothing is ever accepted as signed by the zero address. viem's verifyMessage
  passes 0x0 with a garbage signature (the ERC-6492 path compares ecrecover's
  failure value to it), and 0x0 was the marker holding retired usernames, so
  anyone could release a retired name. verifySig refuses 0x0, and retired
  names are now held by a 'retired' marker that is no wallet.
- Admin approve / reject sign the code of the claim the admin reviewed and act
  only on that claim while it is still pending (409 otherwise). A wallet has
  one claim waiting at a time: a newer one retires the older, and a
  verification settles any left over.

Attribution
- Operations with a paymaster (73% of v4 smart-wallet swaps on Base, measured
  over ~2,000 blocks) include the paymaster's postOp in the same log window,
  and it could swap too. Such a swap is credited to the account only if the
  account itself moved that token inside its own operation; otherwise it
  stays on the sender. On a live sample, 189 of 192 sponsored swaps are still
  credited to the account. Refusing every paymaster operation, as suggested,
  would have dropped all of them.
- Receipt-pending rows are retried at any age, not only within the last day
  (own small partial index); the unchecked-row index is back to IS NULL.

Correctness
- Username hold and current owner are read in one statement (one snapshot),
  so a rename or delete committing in between cannot hand out a held name.
- Taking back your own held name skips the rename clock only as an undo,
  within a day of letting it go; two names can no longer be swapped back and
  forth daily.
- reject_x clears the account binding a paused verification carried, so it
  cannot block another wallet's approval or the real owner's re-check.
- The weekly re-check re-tests handle and post on every write, so a
  re-verification during the fetch is never paused or overwritten.
- /api/profile/check answers 503 (not "available") when it cannot check; the
  form then shows nothing, and a rate-limited check no longer blocks saving.
- Names on screen are watched: one shared timer re-asks for names older than
  the TTL and retries failed lookups with backoff (30 s up to 5 min).
2026-10-08 08:06:54 +08:00
Kevin Codex b280f1e67b fix(profiles): CodeRabbit full review 2 (moderation under the profile lock, no transfer shortcut for swaps)
- Moderation: every admin action now runs in one transaction under the
  profile lock that save, delete and verify take first, and re-reads the
  profile and its pending code inside it. An approval racing the owner's
  handle change (@a -> @b) used to mark @b verified for a post by @a; it is
  now refused. reject_x, remove_x and reset_username get the same treatment.
- Swap attribution backlog: the set-based shortcut is gone. It settled rows
  from a matching token transfer, which for legacy rows (no tx_from) could
  leave an EntryPoint swap on its bundler. Every open row is now settled from
  the chain (traderOf); token transfers are never the evidence.
  To keep a long history from holding up indexing, chain reads stop after
  5 s per pass (the rest wait for the next poll), and a row whose evidence
  was unreadable is not read again until its 10-minute gap has passed, so
  unreadable rows at the head of the queue never starve the rows behind them.
2026-10-08 00:00:00 +08:00
Kevin Codex 60bbf2d258 fix(profiles): CodeRabbit full review (review hand-off race, receipt-pending swaps, malformed profile paths)
- X review hand-off: the code and the profile now change in one transaction
  under the profile lock that save, delete and moderate take first, after
  re-reading the profile. A save landing while X is being asked can no longer
  leave the profile pending review with no code behind it (it is refused with
  the usual "save again" message). The verified path takes the same lock
  first, so the same rows are never locked in opposite orders.
- Indexer: a swap in a transaction sent to an EntryPoint whose receipt could
  not be read is stored as trader_via 'receipt_pending' (also when the
  backlog pass learns it), which the transfer shortcut never settles: token
  transfers are not evidence. Those rows are retried and retire to 'unread'
  after three spaced tries, as before. The partial index covers both open
  states, and both backlog queries still use it.
- /u/<username> and its link card: a malformed escape left after Next's own
  decode is an unknown profile (404) instead of a thrown URIError
  (usernameFromPath, shared by both).

Not changed: "use x_state for the tick" — shape() only sets `x` when the
status is verified (server.ts), so pending and reverify profiles never show
the tick or the handle.
2026-10-07 22:54:35 +08:00
Kevin Codex 2494eea86d fix(profiles): PR review (verify key, capped body reads, airtight post text, docs)
- X verify: the code goes into a public post, so anyone who read it held
  the wallet and the code and could spend the owner's tries or, during an
  X outage, push a post of their choosing into the admin queue. Saving now
  also returns a private verify key (16 random bytes, stored as sha256,
  compared in constant time) that only the signer's browser receives and
  that never appears in the post; verifying requires it, and a request
  without it spends nothing and says nothing. One signature, as before.
- readJson enforces the size cap before buffering: a declared length over
  the cap is refused, and the stream is read in bytes and cancelled the
  moment it passes the cap (route handlers have no body limit).
- oembedText (CodeQL incomplete multi-character sanitization): after tags
  are dropped and entities decoded, every remaining angle bracket goes, so
  no markup can survive in the text (it is only matched, never rendered).
- One-line doc comments on the functions this PR adds or changes.
2026-10-07 21:41:41 +08:00
Kevin Codex 27599317d0 fix(profiles): review round 2 (signing chain, execution-window attribution, bounded backlog)
- signatures: a plain wallet is recovered locally (no RPC); a smart wallet
  is checked on the chain it signed on (Coinbase Smart Wallet and Safe
  bind the chain id), only where it is deployed or nowhere yet; deployed
  only elsewhere → "switch your wallet to <chain>". Round 1 verified on
  the first chain with code, which locked out wallets signing elsewhere.
- attribution: a swap counts for a 4337 account only inside its
  operation's execution (after the EntryPoint's BeforeExecution, before
  its UserOperationEvent); a swap made during bundle validation stays on
  the sender. The 7702 rule is dropped (relayers keep the trade, as
  before this branch). Checked on live Base v0.6 / v0.7 / v0.8 traffic.
- backlog: the last day is always retried (a failed live lookup heals
  without the flag); full history still needs LAUNCH_ATTRIBUTE_BACKLOG=1;
  a row whose evidence stays unreadable for 3 tries is marked 'unread'
  so it cannot stall the queue.
- deleting no longer restarts the rename clock; coming back follows the
  same clock as a rename. Moderation reaches deleted profiles.
- the username check never treats the retired-name placeholder as you.
- migrate: SET LOCAL lock_timeout 10s, so an ALTER never queues behind a
  long read (and blocks reads behind it); a timeout fails the release.
2026-10-07 18:48:45 +08:00
Kevin Codex ff9611281d fix(profiles): review round 1 (attribution evidence, verify needs the code, admin queue, holds, soft delete)
Attribution no longer reads token transfers: anyone can buy and have the
tokens sent to someone else's wallet, which credited the trade to them
(and counted them as an outside trader). A swap now moves off tx.from
only with proof the account authorized the call: an ERC-4337 EntryPoint
transaction credits the sender of the UserOperationEvent that closes the
operation containing the swap; a relayed EIP-7702 call credits the
delegating account. It is decided at index time, so the receipt-path
race that marked smart-wallet swaps as the bundler's is gone. History is
checked behind LAUNCH_ATTRIBUTE_BACKLOG=1: a set-based pass marks swaps
whose sender moved the token itself, the rest get the on-chain evidence.

Profiles:
- verifying needs the issued code: a stranger can no longer probe the
  claimed handle, spend someone's rate limit or push a post into review
- the admin queue is a signed read, shows the exact code issued for each
  pending claim, approves only that claim, refuses a handle another
  wallet has verified, and can remove a tick
- a dropped name is held only if it was kept a day or more; holds yield
  to the verified owner of that X handle; retired names stay retired
- delete keeps the row, so moderation flags, created_at and the rename
  clock survive a re-create
- the weekly re-check confirms the post is still by the bound account
  (a rename on X updates the handle; another account pauses the tick)
- signatures verify on the chain where the wallet's code lives
- the code must be its own word in the post; X ids must be decimal
- editing keeps a pending X claim known to this browser
2026-10-07 18:48:45 +08:00
Kevin Codex 9203ff1874 feat(profiles): usernames, X-verified tick, smart-wallet trade attribution
Profiles give a wallet a public username, name, picture and bio, shown
wherever the wallet appears (trades, holders, comments, "launched by",
fee recipients, the community feed) and on /u/<username> with a link card.

Every write is a wallet signature over a message that spells out every
field (EOA, ERC-1271 and ERC-6492 all verify), checked before its
single-use nonce is spent. Usernames are a-z 0-9 _, brand / chain / staff
names and routes are reserved, a dropped name is held 30 days for its old
wallet, and a name changes once per 30 days (free on the first day, and
always to claim your own verified X handle from an unverified squatter).

The tick needs one public post on X carrying a one-time code bound to the
wallet and the handle it signed for, so a copied code is useless from any
other account. The post is read from X's oEmbed and embed endpoints plus
fxtwitter, no API key; one X account belongs to one wallet; the claimed
handle stays hidden until verified; posts are re-read about weekly and a
deleted one pauses the tick. If X answers nobody, an admin approves it
from /admin. The post earns nothing by itself.

Swaps were credited to tx.from, which for ERC-4337 smart wallets is the
bundler: their trades would never show their name. The indexer now
credits the wallet that actually took or paid the token when the sender
never touched it (attribution.ts), keeps tx_from for the record, and
drains existing history a batch per poll.
2026-10-07 18:48:45 +08:00
Vasanth T d5f0555f14 feat(launch): optional creator banner, stored and edited like the logo
One nullable column (bb_launch_meta.banner_url), the upload route picks its processor and byte cap from the role, and the edit sheet offers it. Signed like every other edit.

(cherry picked from commit 51e694d19f)
2026-10-06 16:30:35 +05:30
Kevin Codex 49778b3f39 fix(launchpad): show launches paired with unlisted ERC-20 quotes properly
The factory accepts any ERC-20 as the quote. A quote not on our lists showed as
"?", with 18 decimals assumed and no warning (SHEAF/MUSEWORLD on Base).

- indexer reads symbol/name/decimals of unlisted quotes into bb_quote_tokens
- new quote key "other": token's own symbol, cleaned, and replaced by its
  address when it is or looks like a listed asset (USDC, ETH, GITLAWB, stocks)
- real decimals; trading stays disabled until they are known
- "Unlisted pair" badge on token page, list rows and dashboard; trade panel
  notice with the pair token address; never priced in USD, never in Trending
2026-09-25 23:25:07 +08:00
Kevin Codex 96ff128dc5 chains: one registry instead of Base-or-Robinhood branches
Every per-chain value now comes from a Record<ChainKey, …> keyed off a pure
chain list (src/lib/chainKeys.ts), so adding a chain fails the typecheck until
it has its RPC env, explorer, deploy block, swap site, stock source, GITLAWB
address and form copy. Before, ~25 two-way ternaries sent any non-Base chain
down the Robinhood arm (RPC env, deploy block, stock registry, copy), and the
sitemap, health route and posts regex hardcoded both chain ids or names.

- chainKeys.ts: keys, ids, labels, explorers, isChainKey/chainKeyOf/chainKeyOr,
  chainList for copy; seo.ts reuses it instead of a second ChainKey
- server env lookups (RPC, deploy block) and NEXT_PUBLIC dev RPCs as records
  (literal process.env reads kept so Next still inlines them)
- stocksServer: explicit STOCK_SOURCE per chain; unknown chains get no stocks
- GITLAWB_ADDRESSES allows null; the gitlawb SQL match is built from it
- wagmi config, chain toggles, network glyphs, badges, locker proof links and
  hero per-chain counts all map over CHAIN_KEYS
- schema: drop the chain_id DEFAULT 8453 so an insert without a chain fails
  instead of being filed under Base (every insert already names chain_id)
- chainKeys.test.ts guards against new chain-name branches and hardcoded ids

No behaviour change for Base or Robinhood.
2026-09-16 21:26:04 +08:00
Kevin Codex 9a65ad4edf fix: signed-or-insert-only metadata, salt-search-stable URI, atomic trade/fee accounting; CI, contributing, security policy
- Metadata registration is INSERT-only for unsigned callers: the creator registers before broadcasting
  (salt still private); replaying the public launch params returns 409 and cannot overwrite image /
  description / website. Later changes go through the wallet-signed edit flow. (review finding 1)
- The on-chain metadataURI is keyed by a client-chosen meta_key instead of the salt, so findSalt's
  result stays valid for USDG / stock quotes. Proven on the live Robinhood factory: 17/100 deterministic
  USDG launches reverted with the old scheme, 0/100 with the new one. (finding 2)
- Swap and fee ingestion commit the event row and the launch totals in one transaction; a rebuild script
  recomputes totals from raw events if ever needed (production checked: no drift). (finding 3)
- Fresh checkout: npm run migrate reads DATABASE_URL from .env.local/.env; fly-secrets.sh is self-contained
  (FLY_APP, SECRETS_ENV); README run + deploy steps corrected.
- CI workflow (lint, typecheck, unit tests, production build; forge build + unit tests), CONTRIBUTING.md,
  SECURITY.md with private reporting.
2026-09-07 11:48:37 +08:00
Kevin Codex 312489a7b4 Initial commit
openlaunch — launch a token. Free. Open source. On Base or Robinhood Chain.
Contracts (LaunchFactory / LaunchLocker / LaunchToken, Foundry), the site (Next.js),
brand assets. No platform fee, liquidity locked forever.
2026-09-07 11:28:24 +08:00