GITLAWB and TWIG are moving to one new token, so new launches should not pair
with TWIG. TWIG moves from the Base form's quotes to the official quotes, the
same move GITLAWB made when TWIG took its place.
- Form: Base offers ETH and stocks; the TWIG option, the "Pair with TWIG"
nudge and the TWIG-only copy are gone. Robinhood Chain still offers GITLAWB.
- The 199 launches already paired with TWIG keep their badge, USD price,
TWIG filter, Buy with ETH route, trade panel, share card and fee burns.
- /api/quotes?chain=base lists ETH only.
- Copy: rules (step + FAQ with a migrate.twigpine.com link), agents page,
llms.txt, pairs page and copy, pair application, README, docs/pairs.md,
the TWIG fork test comment.
- Base landing "Quote assets" now lists stocks beside ETH.
- Tests: form never offers TWIG (config, form source, quotes API), TWIG
stays known, filtered and priced, and no page or doc says the form offers
it in any wording.
Before Robinhood's stock registry has had a good read, its stock list was
empty, so a pair named like a Robinhood stock passed the symbol check. The
free check now marks the symbol unknown (the check does not pass), and a
listing is refused with a 503 until the list reads. Base keeps its static
list and is unaffected.
- Every pair signature and the /admin sign-in now ask the wallet to switch
to Base first (walletOnChain): getWalletClient refuses a chain the wallet
is not on, so a Robinhood applicant got a raw wagmi error.
- recordPairBurn counts a transfer on Base only (PAIR_FEE_CHAIN), not just
wherever a TWIG address is configured.
- A pair's symbol may not be a stock ticker on its chain, nor a symbol
listed on the other chain (a Robinhood copy named MUSEWORLD would carry
its badge).
- The listing refusal names the chain's own core quotes (coreQuoteNames,
now in config.ts).
Pairs were Base-only. Robinhood communities already launch on openlaunch
(DottieLand has launches paired with DOTTIE) and could not apply.
- PAIR_CHAINS = Base and Robinhood Chain. The free check runs unchanged
there: GeckoTerminal's network id is the chain key, GoPlus covers 4663,
and the v4 key, price and transfer probe read from Robinhood's RPC.
- The fee stays TWIG on Base for a token on either chain. A burn is now
matched to its application by id: it was matched on the burn's chain
too, so a Robinhood application could never have been paid.
- Every pair signature (applicant, review, admin) is checked on Base
(PAIR_FEE_CHAIN), the chain the applicant pays on.
- The apply form gets a chain picker (a check never carries over to the
other chain); the application page signs with the application's chain
and pays on Base; the admin panel lists, suspends and edits pairs on
either chain; the review queue says which chain.
- The pool check names each chain's own core quotes (USDG, ETH or
GITLAWB on Robinhood). llms.txt and docs/pairs.md name both chains.
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.
- A request never ends admin sessions. While a change to ADMIN_WALLETS rolls
out, a machine still on the old list ended a newly added admin's sessions;
it now only refuses them. Ending a removed wallet's sessions for good stays
with the boot sweep, which now retries (3 tries, 10 s apart).
- Admin actions use their own gate, requireAdminAction(req, wallet): the
site's own Origin, a session, and the session's wallet signed it. A missing
or malformed wallet is a 400, never let through as a read. requireAdmin(req)
is for reads only.
- "Approve without fee" waits while the applicant may be paying: under a live
fee quote, or in the hour after it while their transfer may still be
indexing (the same hour expiry already waits). Checked before the claim and
again inside it; the panel shows when it can be waived instead of the
button. Applications carry settling_until for that.
/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.
- expiry ignores a stray burn sent before the application existed, so it
cannot keep an unpaid application (and its token's place) alive
- docs: a no-code failure also suspends only after 24 hours
- the monitor treats "no contract code read" as a soft failure (24 h),
not an instant suspension: one lagging node can answer 0x, and a token's
code cannot vanish. Different code still suspends at once.
- llms.txt explains the application fee only while PAIRS_APPLY is on, so
no agent burns TWIG for an application that cannot exist yet
- a burn sent before its application was created never counts and is never
retried (an early or stray burn whose tag happens to name it)
- 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
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
CodeRabbit on #102: `GET /api/boost?fresh=1&wallet=<admin>` trusted a wallet named in the URL, so anyone who knew an
admin address could skip both lane caches. The public read no longer has a fresh path at all. Instead the signed,
single-use moderation request (POST /api/boost/mod, admin signature + nonce, already verified before any write)
answers with the lane read fresh, and the admin panel shows that lane at once; if that read fails the write still
stands, the card still leaves at once and a cached read follows. Verified locally with signed hide/unhide requests;
a forged one gets 401.
- /admin reads the lane fresh (GET /api/boost?fresh=1&wallet=<admin>, skipping both few-second memos), so a reload
right after a hide never shows the token again from the other machine's cache. Replaces the 15 s client timer,
which brought the stale card back once it ran out. Public reads and polls keep the cached lane.
- Undo the shared first-page memo on /api/launch/list: Just Launched fetches the list once per new launch and a 2 s
cached page could miss it for good; caller-chosen keys could also crowd the shared memo. The picker's trending
stays bounded by its one-minute client cache.
- The token page's clicks are the lane card's own count (laneClicks), read only for a token boosted in the window.
- One X intent builder: intentUrl(text, { url, via }) in profiles/xpost.ts, used by the booster's post and the token
page's Share.
- Token page: while boosted, the Boost panel shows "N clicks from the lane in the last 24 hours" (getTokenBoost counts
the cards' own window), from 10 up so a fresh boost never reads like a small number. A failed count is no line.
- Done screen: "Share on X" opens the booster's own post, "Boosted $SYM with 1M TWIG on openlaunch.lol. The TWIG went to
0x…dEaD.", the token's page as its link, via @openlaunch_lol (boostShareUrl; the token page's Share intent).
- /api/launch/list: a first page with no wallet filter shares the live poll's 2 s memo under one key (listPageKey), so
the picker's trending, the palette and the launches list's first page cost one query; paged and per-wallet reads
are never shared.
- /admin: a token hidden from the panel stays out of its list for 15 s, past both machines' lane caches.
- llms.txt: the lane's fields as they are (names, no entry).
- The entry line is read off the cards the lane shows (boostEntryLine(totals)): it can no longer advertise an open
spot or a 5th-place total the cards contradict. The lane payload drops the server's ranked `entry`.
- Credit names come with the lane (`names`, null = no profile) and seed a NamesProvider, so the first paint shows
"by @name" with no address-to-name swap and no client lookups.
- GET /api/boost reads the lane from the same few-second cache as pages and polls: polling it adds no database pass.
- The picker asks only for what it shows: "Your launches" through a new `launcher=` filter on /api/launch/list
(not /api/me), trending cached a minute across opens. A token an admin keeps out of the lane says "Not shown in the
lane" in the picker instead of sending the visitor to a page with no Boost button.
- One "from 100K TWIG" phrase (BOOST_FROM_FLOOR) for the line and the open-place buttons.
- The lane's list is labelled "Places in the Boosted lane": it holds the cards and the open places.
- docs/boost.md: the stale "Latest burn" paragraph.
- "Boost a token" in the lane header on every screen size; each open place is a button from 1024px, one
"+ N open spots · Boost a token" row below that. All open the picker (BoostPicker, loaded on first use): the
connected wallet's own Base launches first, a Base search, or trending on Base. Picking goes to
/t/base/<token>?boost=1#boost, where the dialog opens with every check it has; nothing is sent from the lane.
- What it takes to get in, beside the title (its own line on a phone): "2 open spots · from 100K TWIG", or
"Full · 5th place holds 1.2M TWIG". The lane read carries `entry` from the same ranked list the token page uses.
- Each card credits the wallet that sent the most TWIG for it in the last 24 hours ("by @name ✓" via WhoName, the
short address otherwise; ties go to the newer boost, then the address). The credit replaces the card's "Latest
burn" link; the burns stay on the token page's Boost panel. A failed credit read never fails the lane.
CodeRabbit on #100: an IPv4-mapped address written in hex groups (::ffff:cb00:7107) missed the dotted-only pattern
and fell into 0:0:0:0::/64, so every such IPv4 client shared one key (one rate bucket, one click per 30 minutes).
boostClickAddress now validates with node:net isIP, expands the address to its eight groups (a dotted IPv4 tail
included), and maps the whole ::ffff:0:0/96 range to its IPv4 address, dotted or hex; any other IPv6 is its /64,
anything that is not an address is its own key. It moves to a server-only module (boostAddress.ts): boost.ts ships
to the browser with the lane and must not import node:net. A guard test holds that.
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.
- The beacon takes only the site's own pages (Sec-Fetch-Site same-origin, or a matching Origin): another site can no
longer raise a card's public count from its visitors' browsers (a text/plain beacon needs no CORS preflight).
- Views are tallied in memory and written once every 10 s per machine, not one upsert on the same hot rows per page.
- Click rows are pruned after 90 days (hourly, off the request path); view rows are hourly counts and stay.
- /admin: views, clicks and burns start at the same whole hour; totals are window sums over every token, not the 100
listed; visitors have no total (counted per token); a period never shows another period's error.
- The lane sends no view for its empty line; the hover arrow is hidden under 640px (it covered the TWIG figure).
- The expiry filter runs once, where the cached lane is read, for pages, polls and beacons alike.
- 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.
- 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).
- dialog: "Check again" settles with the same rule as a direct receipt
(showsAfter), so a confirmed boost under a full lane's 5th place is never
reported as "in the Boosted lane"
- admin: a non-JSON error page reports the HTTP failure, not a parse error;
a failed or unreadable lane read keeps the last list and never throws
- llms.txt: the formula states its bounds (whole TWIG >= the floor,
0 < token_id < 10^11) and the top-5 rule
- docs/boost.md: every repo path in the app/src/... form
The lane's motion follows the Trending board: nothing moves on its own.
- fuse: a warm line along each card's bottom edge, the share of its 24 hours
left, stepped with the card's clock
- ignite: when a poll shows a card with more TWIG and a boost under 3 minutes
old, one inset warm ring, the amount rolling up, a few embers; places slide.
Reduced motion keeps only the colour ring.
Review fixes:
- copy no longer promises a place: the lane shows the 5 largest totals; the
dialog says before sending when a full lane would leave the token under the
5th place and offers the amount that shows it now; the done screen names
the place or says it is not in the lane yet
- a sync that indexed a boost and an admin hide drop the lane cache, so the
booster's place and the admin list are fresh; the panel reads once more
after 6 s for the other machine; the admin list drops a hidden card at once
- hero totals count boosts only while BOOST is on and only when they name a
launch (switch moved to boostSwitch.ts so queries.ts needs no lane import)
- the live poll carries one boost_on boolean on every page (seeded by the
layout, kept across a tick from an older machine); the lane's cards come
only with ?boost=1 while a lane is on screen
- the lane backfills from spare candidates instead of leaving a hole
- /base keeps the lane mounted so a switch-on reaches an open tab
- compact amounts never read "1000K"
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.
CodeRabbit (full review): loadEarnInfo kept a successful answer for the
whole browser session, so after EARN was switched off an open tab kept
showing the /me card and the command-palette entry until a full reload,
which blunted the kill switch.
It now shares only the request in flight and forgets the answer once it
settles: every /me visit or palette open reads the switch as it is now. A
failed request still reads as off. Deliberately no periodic re-poll: a
network blip would then hide the card in the middle of a transaction.
CodeRabbit (full review): the batch path called sendCalls with no
simulation, although every other send is simulated first, and EIP-5792 does
not require the wallet to simulate. Simulating the deposit on its own would
fail for the wrong reason, since its approval only exists inside the batch.
The [approve, deposit] batch is now simulated the way it will run: the
approval, then the deposit with the approval's only effect (the allowance)
applied as a state override on USDC (Circle FiatToken v2.2, `allowed` at
slot 10; the opt-in live test proves the override lands on Base). The
override is trusted only after a read shows it took; otherwise the wallet
gets the one-at-a-time path, where each call is simulated on its own. A
deposit that reverts with the allowance in place stops before any prompt. A
withdrawal batch is one call and is simulated first.
Keeping the batch (rather than always using the one-at-a-time path when an
approval is needed, as suggested) keeps one prompt and the builder-code
capability for smart-wallet deposits: approvals are exact, so nearly every
deposit needs one.
Review round 2 on PR 96 (Vasanth, CodeRabbit):
- A receipt timeout or RPC error after the wallet returned a hash (or a
batch id) set an error and unlocked the form with the amount still filled
in, so a second deposit could go out while the first was pending or done.
Such a send is now "unconfirmed": the form stays locked, the transaction
link shows, and "Check again" asks the chain (or the wallet, for a batch)
again. Only a final receipt or batch status ends it; a reverted one is the
only failure after sending that allows sending again.
- Every wallet prompt is bound to the account that started the action
(getWalletClient({ account }), account on each write) and preceded by a
check that it is still the connected account. A switch mid-action stops
it before the next prompt instead of falling back from the batch path to
a prompt for the other account. Leaving the page stops it too.
- The vault's terms are read again after an approval confirms, right before
the deposit is prepared, so a change during the approval wait stops it.
- A failed latest terms poll no longer lets wagmi's last good data outrank
the server's check (liveCheck).
earn-flow.test.ts runs the real deposit / withdraw / recheck code against
inert wallets and RPCs; with each fix reverted, its tests fail.
The pre-deposit check stored its "paused" reason in state and nothing ever
cleared it, so after one failed check the form stayed on "Deposits paused"
until the card remounted, even if the vault went back to the reviewed terms.
Clearing it after a passing check would not help: the button is disabled
while paused, so that check never runs again.
The pause is now derived, not stored: the open card re-reads the vault's
asset, fees and gates every minute (one multicall), and depositPause() takes
that read over the server's up-to-10-minute-old figures. A failed send-time
check refreshes the read so the pause shows at once. The send-time check
itself is unchanged and still guards every deposit.
Base now pitches apps on routing their users into lending markets on Base
(base.org/earn-borrow). This adds the one integration worth shipping: a
fee-free, non-custodial Earn card on /me that puts a wallet's own USDC into
Steakhouse Prime USDC (a Morpho Vault V2 on Base) and takes it out again.
- Off unless the runtime secret EARN=on is set; GET /api/earn then says only
{ enabled: false } and the card and the command-palette entry stay hidden.
- Plain ERC-4626 calls with exact approvals and a simulation before every
signature. No SDK, no new dependency, no new host; the CSP is unchanged.
- Every call ends with the bc_ly9ism19 builder-code suffix, so Base credits
the deposits to us; smart wallets that batch also get it as the sendCalls
dataSuffix capability.
- Deposits stop if the vault's fees, gates or asset ever differ from the
reviewed terms; withdrawals never depend on that check.
- The rate shown is the vault's real share-price growth over 30 days, read
on-chain by the server, so nobody else's number is involved.
Verified on a Base fork (exact approve, deposit, partial withdraw, redeem all,
suffix decoded to bc_ly9ism19) and by an opt-in live read test. A real-wallet
deposit and withdrawal is still to do before the switch is turned on
(docs/earn.md).
Debounce unsigned quotes without locking amount or route edits.
Cancel stale requests and show verified fee-limit diagnostics.
Keep both 5% limits and all wallet approvals/deposits explicit.
Refs #50
Explain missing, queued and underpriced approvals without treating
missing receipts as failed transactions. Block visible wallet queues
before a new approval or deposit signature.
Verify mined replacement hashes against the exact approval and fresh
allowance. Prevent delayed polling from undoing confirmed recovery.
No nonce overrides, automatic retries or contract changes.
otherBridgeChain had no callers. docs/bridge.md now records the
maintainer's Base USDC to Arc transfer on 2026-09-16 and what the wallet
test surfaced: Phantom cannot add Arc or Robinhood, the proxy's whole-batch
403 on eth_fillTransaction, public-node rate limits, and why publicnode is
unsuitable as a proxy upstream.
Public Base and Arc nodes rate-limit a single quote's burst of reads. They
answer inside a 200 body, sometimes with one object where a batch array was
due, which viem cannot use ("unknown RPC error" or a TypeError in its
batch scheduler). The proxy now maps rate-limit and malformed replies to
HTTP 429/502 so viem retries with backoff, and Arc reads go through the
proxy (ARC_RPC_URL upstream, else the official node) instead of straight
from every browser. The Arc origin leaves the CSP; a dev override
NEXT_PUBLIC_RPC_URL_ARC is allowed like the other chains.
Neither file is read by code or tests. They are output templates from the
contributor's design tooling and duplicate theme tokens that already live in
the stylesheet. docs/bridge.md remains the bridge documentation.
Review fixes on the Relay bridge:
- A sped-up or cancelled-and-replaced deposit no longer blocks tracking
forever. When the wallet's hash is unmined and Relay reports a different
source hash, that hash is verified as this wallet's exact deposit for the
same order and adopted.
- A record with no deposit anywhere can be discarded after 15 minutes, only
while Relay still reports "waiting" with no hashes and the source chain
has no receipt, observed within the last minute. Unmined approvals get the
same bounded discard; a late-mined approval only grants the exact
allowance every deposit re-reads.
- A wallet chain change during a quote no longer leaves the form locked on
"Finding your route…".
- Quote validity travels as ttlMs and is anchored on the browser clock at
request time, so device clock skew cannot expire or extend a quote.
- Fetch timeouts use a hand-linked signal instead of AbortSignal.any, which
older in-app wallet browsers lack.
- Relay's chain catalogue is cached per fetcher for a minute instead of
re-downloaded on every quote; an unverifiable copy is dropped.
- The USDC approval is no longer gated on the 45 s quote TTL, since a fresh
quote is required after it anyway.
- Wallet and RPC errors go through friendlyError; the dialog states that
Openlaunch does not operate Relay or hold funds in transit.
Unit tests cover the hash adoption rule, the quoting-lock reset, expiry
anchoring, the linked timeout signal, both discard gates and the catalogue
cache. 621 tests, lint, typecheck, build and the read-only live route suite
pass.
Let users fund the same wallet across Base, Robinhood and Arc without leaving Openlaunch or deploying new contracts. Validate Relay orders and deposits, require exact USDC approvals, and preserve uncertain transfers for recovery instead of resending.
Include route, approval, recovery and UI regression tests, official asset provenance, and rollout documentation. Keep Robinhood USDC disabled because current routes fail the deposit and impact safeguards.