diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index ef366d22..f896acd2 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import { useAccount, useBalance, useConfig, useReadContract, useSwitchChain } from "wagmi"; import { getPublicClient, getWalletClient } from "wagmi/actions"; @@ -15,6 +15,7 @@ import { ERC20_MIN_ABI, ERC20_TRANSFER_EVENT, LAUNCH_FACTORY_ABI, PERMIT2_ABI, U import { BPS, DEFAULT_SUPPLY, FEE_PRESETS, MCAP_PRESETS, TICK_SPACING, launchpad, quoteUsdOf, type Quote } from "@/lib/launchpad/config"; import { fdvForStartTick, fmtCompact, fmtQuoteUnits, fmtUsd, initialBuyPreview, minOut, startTickForFdv, tickToTokensPerQuote, units } from "@/lib/launchpad/math"; import { BUY_PRESETS, defaultFirstBuy, suggestFirstBuy } from "@/lib/launchpad/first-buy"; +import { getFirstBuyDeclined, getFirstBuyDeclinedServer, setFirstBuyDeclined, subscribeFirstBuyDeclined } from "@/lib/launchpad/first-buy-session"; import { encodeV4ExactInSingle, type PoolKey } from "@/lib/launchpad/swap"; import { stockMcapPresets } from "@/lib/launchpad/stocks"; import { GITLAWB_SITE, gitlawbMcapPresets } from "@/lib/launchpad/gitlawb"; @@ -51,7 +52,6 @@ const PERMIT_EXPIRY_S = 30 * 24 * 3600; const LAUNCH_GAS_WEI = 1_000_000_000_000_000n; // 0.001 ETH: deploy + pool init + position mint const BUY_GAS_WEI = 500_000_000_000_000n; // 0.0005 ETH: approvals + swap const GAS_RESERVE_WEI = LAUNCH_GAS_WEI + BUY_GAS_WEI; -const FIRST_BUY_DECLINED_KEY = "ol:first-buy-declined"; // session only: a creator who cleared the suggestion is not nagged on the next launch function randomSalt(): Hex { const b = new Uint8Array(32); @@ -191,10 +191,16 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = " const [typedBuyFor, setTypedBuyFor] = useState<{ amount: string; quoteId: string } | null>(null); const quoteId = `${chain}:${quote.address.toLowerCase()}`; const typedBuy = typedBuyFor && typedBuyFor.quoteId === quoteId ? typedBuyFor.amount : ""; - // Read once at mount. Safe for hydration: no wallet is connected on the first render, so the suggestion is absent either way. - const [buyDeclined, setBuyDeclined] = useState(() => { try { return typeof sessionStorage !== "undefined" && sessionStorage.getItem(FIRST_BUY_DECLINED_KEY) === "1"; } catch { return false; /* storage blocked: suggest as usual */ } }); - function declineFirstBuy() { setTypedBuyFor(null); setBuyDeclined(true); try { sessionStorage.setItem(FIRST_BUY_DECLINED_KEY, "1"); } catch { /* ignore */ } } - function chooseFirstBuy(v: string) { setTypedBuyFor({ amount: v, quoteId }); setBuyDeclined(false); try { sessionStorage.removeItem(FIRST_BUY_DECLINED_KEY); } catch { /* ignore */ } } + // Declined for this tab session (the "No first buy" button) is an external store read with useSyncExternalStore: the + // server snapshot is false, so server and client markup match during hydration and the client re-renders with the + // real flag right after (lib/launchpad/first-buy-session.ts). Clearing the field or a chip declines in memory only. + // Both states are visible on the form with a way back, so a creator who cleared it while exploring never wonders where it went. + const sessionDeclined = useSyncExternalStore(subscribeFirstBuyDeclined, getFirstBuyDeclined, getFirstBuyDeclinedServer); + const [declinedNow, setDeclinedNow] = useState(false); + const buyDeclined = declinedNow || sessionDeclined; + function declineFirstBuy(forSession = false) { setTypedBuyFor(null); setDeclinedNow(true); if (forSession) setFirstBuyDeclined(true); } + function suggestAgain() { setTypedBuyFor(null); setDeclinedNow(false); setFirstBuyDeclined(false); } + function chooseFirstBuy(v: string) { setTypedBuyFor({ amount: v, quoteId }); setDeclinedNow(false); setFirstBuyDeclined(false); } // Generated lazily at launch time (a render-time random value would break hydration). const saltRef = useRef(null); // The metadataURI is keyed by meta_key, so findSalt can change the salt freely within one attempt. Both refs are @@ -219,8 +225,9 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = " const nativeBalance: bigint | undefined = ethBal.data?.value; const buyBalance: bigint | undefined = quote.key === "eth" ? nativeBalance : (quoteBal.data as bigint | undefined); const buyBalanceFailed = quote.key === "eth" ? ethBal.isError : quoteBal.isError; - // A suggested buy exists only when the wallet is connected and can cover it and its gas, so it can never block the launch below. - const suggestion = suggestFirstBuy({ quote, connected: Boolean(address) && onChain, balance: buyBalance, nativeBalance, gasReserve: GAS_RESERVE_WEI, declined: buyDeclined || Boolean(typedBuy), parse: parseUnits }); + // The suggested buy is selected from the start; a connected wallet's balances can only take it away (or a failed read), + // so it can never block the launch below. A typed amount keeps the strict checks. + const suggestion = suggestFirstBuy({ quote, connected: Boolean(address) && onChain, balance: buyBalance, nativeBalance, balanceFailed: buyBalanceFailed || ethBal.isError, gasReserve: GAS_RESERVE_WEI, declined: buyDeclined || Boolean(typedBuy), parse: parseUnits }); const initialBuy = typedBuy || suggestion.amount || ""; const buySource: "typed" | "suggested" | "none" = typedBuy ? "typed" : suggestion.amount ? "suggested" : "none"; const initialBuyRaw = parseBuyAmount(initialBuy, quote.decimals); @@ -716,7 +723,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = " {initialBuyRaw && buyBalance !== undefined ? ( balance {fmtQuoteUnits(units(buyBalance, quote.decimals), quote.decimals)} ) : null} - {initialBuyRaw ? : null} + {initialBuyRaw ? : null} {buyPreview ? (

@@ -728,8 +735,10 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = "

Suggested {fmtQuoteUnits(Number(defaultFirstBuy(quote)), quote.decimals)} {quote.symbol}, but this wallet holds only gas. The launch stays free; you can buy on the token page later.

) : suggestion.reason === "no-gas" ? (

Suggested {fmtQuoteUnits(Number(defaultFirstBuy(quote)), quote.decimals)} {quote.symbol}, but this wallet has no ETH left for the buy's gas. The launch stays free; you can buy on the token page later.

- ) : suggestion.reason === "no-wallet" ? ( -

Connect a wallet on {CHAIN_LABEL} to see the suggested amount.

+ ) : suggestion.reason === "unknown-balance" ? ( +

Could not read your balance, so nothing is suggested. The launch stays free; you can still type an amount.

+ ) : suggestion.reason === "declined" && !typedBuy ? ( +

No first buy. The launch stays free.{defaultFirstBuy(quote) ? <> : null}

) : null}

A token with no holders and no price move looks dead on every screener and sits under quiet launches on the home page. Your first buy opens the chart. Clear it and the launch stays free.

Other traders can buy before you. First-buy slippage tolerance: {FIRST_BUY_SLIPPAGE_BPS / 100}%. Network gas and pool fees apply.

diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts index 94c2581c..add7223b 100644 --- a/app/src/components/launchpad/launch-form.test.ts +++ b/app/src/components/launchpad/launch-form.test.ts @@ -29,7 +29,7 @@ test("launch merge retains chain-scoped marks and honest optional-buy copy", () assert.match(source, / declineFirstBuy\(true\)\}/); + assert.match(source, /if \(forSession\) setFirstBuyDeclined\(true\)/); + // hydration: the session flag is read through useSyncExternalStore with a false server snapshot, never in a state initializer + assert.match(source, /useSyncExternalStore\(subscribeFirstBuyDeclined, getFirstBuyDeclined, getFirstBuyDeclinedServer\)/); + assert.doesNotMatch(source, /sessionStorage/, "the form never touches sessionStorage directly"); + assert.match(source, /suggestion\.reason === "unknown-balance"/); assert.match(source, /Other traders can buy before you/); assert.match(source, /First-buy slippage tolerance: \{FIRST_BUY_SLIPPAGE_BPS \/ 100\}%/); assert.match(source, /Network gas and pool fees apply/); diff --git a/app/src/lib/launchpad/first-buy-session.test.ts b/app/src/lib/launchpad/first-buy-session.test.ts new file mode 100644 index 00000000..2c1a46ed --- /dev/null +++ b/app/src/lib/launchpad/first-buy-session.test.ts @@ -0,0 +1,53 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { FIRST_BUY_DECLINED_KEY, getFirstBuyDeclined, getFirstBuyDeclinedServer, setFirstBuyDeclined, subscribeFirstBuyDeclined } from "./first-buy-session.ts"; + +function fakeStorage(): Storage & { store: Map } { + const store = new Map(); + return { store, getItem: (k) => store.get(k) ?? null, setItem: (k, v) => void store.set(k, String(v)), removeItem: (k) => void store.delete(k), clear: () => store.clear(), key: () => null, length: 0 } as Storage & { store: Map }; +} + +test("server snapshot is always false; client snapshot follows the flag; writes notify subscribers", () => { + const g = globalThis as { sessionStorage?: Storage }; + const prev = g.sessionStorage; + g.sessionStorage = fakeStorage(); + try { + assert.equal(getFirstBuyDeclinedServer(), false); + assert.equal(getFirstBuyDeclined(), false, "cold tab"); + let notified = 0; + const off = subscribeFirstBuyDeclined(() => { notified += 1; }); + setFirstBuyDeclined(true); + assert.equal(getFirstBuyDeclined(), true); + assert.equal((g.sessionStorage as ReturnType).store.get(FIRST_BUY_DECLINED_KEY), "1"); + setFirstBuyDeclined(false); + assert.equal(getFirstBuyDeclined(), false); + assert.equal(notified, 2); + off(); + setFirstBuyDeclined(true); + assert.equal(notified, 2, "unsubscribed"); + assert.equal(getFirstBuyDeclinedServer(), false, "the server never sees the flag, whatever the tab holds: that is the hydration guarantee"); + } finally { + if (prev === undefined) delete g.sessionStorage; else g.sessionStorage = prev; + } +}); + +test("storage blocked or absent: writes do not throw and the latest value is served from memory until reload (CodeRabbit, PR #26)", () => { + const g = globalThis as { sessionStorage?: Storage }; + const prev = g.sessionStorage; + delete g.sessionStorage; + try { + setFirstBuyDeclined(false); + assert.equal(getFirstBuyDeclined(), false, "absent storage, nothing declined yet"); + assert.doesNotThrow(() => setFirstBuyDeclined(true)); + assert.equal(getFirstBuyDeclined(), true, "absent storage: the explicit dismissal survives a remount within the tab"); + Object.defineProperty(g, "sessionStorage", { configurable: true, get() { throw new Error("blocked"); } }); + assert.equal(getFirstBuyDeclined(), true, "blocked storage: still the in-memory value"); + assert.doesNotThrow(() => setFirstBuyDeclined(false)); + assert.equal(getFirstBuyDeclined(), false, "and it clears the same way"); + assert.equal(getFirstBuyDeclinedServer(), false, "the server snapshot ignores memory too"); + } finally { + delete g.sessionStorage; + if (prev !== undefined) g.sessionStorage = prev; + setFirstBuyDeclined(false); + } +}); diff --git a/app/src/lib/launchpad/first-buy-session.ts b/app/src/lib/launchpad/first-buy-session.ts new file mode 100644 index 00000000..dda7ffbf --- /dev/null +++ b/app/src/lib/launchpad/first-buy-session.ts @@ -0,0 +1,44 @@ +/** + * "No first buy" for this tab session, as an external store so the launch form can read it with useSyncExternalStore: + * the server snapshot is always false, so server and client render the same markup during hydration, and the client + * re-renders with the real flag right after. (A lazy useState read of sessionStorage rendered the suggestion on the + * server and the cleared state on the client: a hydration mismatch. An effect that sets state is rejected by the lint + * rules.) Only the explicit "No first buy" button writes the flag; clearing the field or a chip is in-memory only. + */ + +export const FIRST_BUY_DECLINED_KEY = "ol:first-buy-declined"; + +const listeners = new Set<() => void>(); +/** The latest value, for tabs where storage is blocked or absent: it then holds until reload, the closest thing to a session such a tab has. */ +let inMemoryDeclined = false; + +export function subscribeFirstBuyDeclined(cb: () => void): () => void { + listeners.add(cb); + return () => { listeners.delete(cb); }; +} + +/** Client snapshot: the flag from storage, or the in-memory value when storage is blocked or absent. */ +export function getFirstBuyDeclined(): boolean { + try { + if (typeof sessionStorage === "undefined") return inMemoryDeclined; + return sessionStorage.getItem(FIRST_BUY_DECLINED_KEY) === "1"; + } catch { + return inMemoryDeclined; + } +} + +/** Server snapshot: never declined, so the first client render matches the server's. */ +export function getFirstBuyDeclinedServer(): boolean { + return false; +} + +export function setFirstBuyDeclined(declined: boolean): void { + inMemoryDeclined = declined; + try { + if (declined) sessionStorage.setItem(FIRST_BUY_DECLINED_KEY, "1"); + else sessionStorage.removeItem(FIRST_BUY_DECLINED_KEY); + } catch { + /* storage blocked: the in-memory value above is what reads return until reload */ + } + for (const cb of listeners) cb(); +} diff --git a/app/src/lib/launchpad/first-buy.test.ts b/app/src/lib/launchpad/first-buy.test.ts index 45d684b2..cd853c00 100644 --- a/app/src/lib/launchpad/first-buy.test.ts +++ b/app/src/lib/launchpad/first-buy.test.ts @@ -8,7 +8,7 @@ const usdg = { key: "usdg" as const, decimals: 6, usd: 1 }; const gitlawb = { key: "gitlawb" as const, decimals: 18, usd: 0.00002 }; const nvda = { key: "stock" as const, decimals: 18, usd: 223.05 }; const GAS = parseUnits("0.0015", 18); // the form's reserve: launch gas + buy gas -const base = { connected: true, declined: false, parse: parseUnits }; +const base = { connected: true, declined: false, balanceFailed: false, parse: parseUnits }; test("default: the first preset for ETH, USDG and GITLAWB; $25 worth for a stock; nothing for a stock without a price", () => { assert.equal(defaultFirstBuy(eth), BUY_PRESETS.eth[0]); @@ -36,25 +36,27 @@ test("amountForUsd: short decimals parseUnits accepts, never more places than th } }); -test("suggestFirstBuy (ETH quote): only when connected and the balance is known and covers amount + gas; every other case launches free", () => { +test("suggestFirstBuy (ETH quote): selected from the start; a connected wallet's balance can only take it away", () => { const enough = parseUnits("0.02", 18); const ethIn = (balance: bigint | undefined, more = {}) => suggestFirstBuy({ ...base, quote: eth, balance, nativeBalance: balance, gasReserve: GAS, ...more }); - assert.deepEqual(ethIn(enough), { amount: "0.01" }); + assert.deepEqual(ethIn(undefined, { connected: false }), { amount: "0.01", provisional: true }, "no wallet yet: the default is shown as selected"); + assert.deepEqual(ethIn(undefined), { amount: "0.01", provisional: true }, "wallet connected, balance still loading: keep it, no flicker"); + assert.deepEqual(ethIn(undefined, { balanceFailed: true }), { amount: null, reason: "unknown-balance" }, "the read failed: drop it so it cannot block the launch"); + assert.deepEqual(ethIn(enough), { amount: "0.01", provisional: false }, "confirmed against the balance"); assert.deepEqual(ethIn(parseUnits("0.0114", 18)), { amount: null, reason: "insufficient" }, "covers the amount and the buy's gas but not the launch's"); - assert.deepEqual(ethIn(parseUnits("0.0115", 18)), { amount: "0.01" }, "exactly amount + both reserves"); - assert.deepEqual(ethIn(undefined), { amount: null, reason: "unknown-balance" }, "a failed or pending balance read never blocks"); - assert.deepEqual(ethIn(undefined, { connected: false }), { amount: null, reason: "no-wallet" }); + assert.deepEqual(ethIn(parseUnits("0.0115", 18)), { amount: "0.01", provisional: false }, "exactly amount + both reserves"); assert.deepEqual(ethIn(enough, { declined: true }), { amount: null, reason: "declined" }); + assert.deepEqual(ethIn(undefined, { connected: false, declined: true }), { amount: null, reason: "declined" }, "cleared stays cleared without a wallet too"); assert.deepEqual(suggestFirstBuy({ ...base, quote: { ...nvda, usd: null }, balance: enough, nativeBalance: enough, gasReserve: 0n }), { amount: null, reason: "no-price" }); }); test("suggestFirstBuy (ERC-20 quote): the token balance must cover the amount and the native balance must cover the gas", () => { const usdgIn = (balance: bigint | undefined, nativeBalance: bigint | undefined) => suggestFirstBuy({ ...base, quote: usdg, balance, nativeBalance, gasReserve: GAS }); - assert.deepEqual(usdgIn(parseUnits("25", 6), GAS), { amount: "25" }, "exact token balance is enough; gas is separate"); + assert.deepEqual(usdgIn(parseUnits("25", 6), GAS), { amount: "25", provisional: false }, "exact token balance is enough; gas is separate"); assert.deepEqual(usdgIn(parseUnits("24.99", 6), GAS), { amount: null, reason: "insufficient" }); assert.deepEqual(usdgIn(parseUnits("100", 6), GAS - 1n), { amount: null, reason: "no-gas" }, "tokens, but one wei short of gas for the launch, the approvals and the swap"); - assert.deepEqual(usdgIn(parseUnits("100", 6), GAS), { amount: "25" }, "exactly the reserve"); - assert.deepEqual(usdgIn(parseUnits("100", 6), undefined), { amount: null, reason: "unknown-balance" }, "native balance unknown: no suggestion, launch stays free"); - assert.deepEqual(usdgIn(undefined, GAS), { amount: null, reason: "unknown-balance" }); - assert.deepEqual(suggestFirstBuy({ ...base, quote: nvda, balance: parseUnits("1", 18), nativeBalance: GAS, gasReserve: GAS }), { amount: "0.11" }); + assert.deepEqual(usdgIn(parseUnits("100", 6), GAS), { amount: "25", provisional: false }, "exactly the reserve"); + assert.deepEqual(usdgIn(parseUnits("100", 6), undefined), { amount: "25", provisional: true }, "native balance still loading: keep it"); + assert.deepEqual(suggestFirstBuy({ ...base, quote: usdg, balance: undefined, nativeBalance: GAS, gasReserve: GAS, balanceFailed: true }), { amount: null, reason: "unknown-balance" }, "token balance read failed: drop it"); + assert.deepEqual(suggestFirstBuy({ ...base, quote: nvda, balance: parseUnits("1", 18), nativeBalance: GAS, gasReserve: GAS }), { amount: "0.11", provisional: false }); }); diff --git a/app/src/lib/launchpad/first-buy.ts b/app/src/lib/launchpad/first-buy.ts index 2ee8c2f0..759306cc 100644 --- a/app/src/lib/launchpad/first-buy.ts +++ b/app/src/lib/launchpad/first-buy.ts @@ -4,10 +4,11 @@ * The launch form pre-fills a small buy (about SUGGESTED_BUY_USD in the quote asset) so a token opens with a holder and * a price move: with neither it reads as dead on every screener and sits under "quiet launches" on the home page. * - * One rule: a SUGGESTED buy never blocks a launch. It exists only when the wallet is connected, the balances are known, - * the quote balance covers the amount, the native balance covers the buy's gas (for an ETH quote both come from the - * same balance), and the creator has not cleared it. In every other case the form launches for free, exactly as - * before. An amount the creator TYPED keeps the strict checks (LaunchForm.tsx). + * One rule: a SUGGESTED buy never blocks a launch. The default is selected from the start, wallet or not, so the form + * always shows what it suggests; once a wallet is connected the balances can only take it away: it is dropped when the + * quote balance cannot cover the amount, the native balance cannot cover the buy's gas (for an ETH quote both come from + * the same balance), or a balance read failed. While a read is still pending the suggestion stays, marked provisional. + * The creator can clear it at any time. An amount the creator TYPED keeps the strict checks (LaunchForm.tsx). */ import type { Quote } from "./config"; @@ -17,8 +18,9 @@ export const SUGGESTED_BUY_USD = 25; /** Quick-pick amounts per quote; the first one is the suggestion (about $25 at the prices this was written at). */ export const BUY_PRESETS: Record = { eth: ["0.01", "0.05", "0.1", "0.25"], usdg: ["25", "100", "250"], gitlawb: ["500000", "1000000", "5000000"], stock: [] }; -export type NoSuggestion = "no-price" | "no-wallet" | "unknown-balance" | "insufficient" | "no-gas" | "declined"; -export type FirstBuySuggestion = { amount: string; reason?: undefined } | { amount: null; reason: NoSuggestion }; +export type NoSuggestion = "no-price" | "unknown-balance" | "insufficient" | "no-gas" | "declined"; +/** `provisional`: shown as selected but not yet confirmed against a balance (no wallet yet, or the read is pending). */ +export type FirstBuySuggestion = { amount: string; provisional: boolean; reason?: undefined } | { amount: null; reason: NoSuggestion }; function trimZeros(s: string): string { return s.includes(".") ? s.replace(/0+$/, "").replace(/\.$/, "") : s; @@ -52,6 +54,8 @@ export type SuggestInput = { balance: bigint | undefined; /** Native balance, which pays the buy's gas (and the approvals an ERC-20 quote needs first); undefined while unknown. */ nativeBalance: bigint | undefined; + /** A balance read failed (as opposed to still loading): the suggestion is dropped rather than left blocking the launch. */ + balanceFailed: boolean; /** Native amount kept back for that gas. */ gasReserve: bigint; /** The creator cleared the suggestion (this session). */ @@ -64,11 +68,11 @@ export function suggestFirstBuy(i: SuggestInput): FirstBuySuggestion { if (i.declined) return { amount: null, reason: "declined" }; const amount = defaultFirstBuy(i.quote); if (!amount) return { amount: null, reason: "no-price" }; - if (!i.connected) return { amount: null, reason: "no-wallet" }; - if (i.balance === undefined || i.nativeBalance === undefined) return { amount: null, reason: "unknown-balance" }; + if (!i.connected) return { amount, provisional: true }; // selected from the start; a wallet can only take it away + if (i.balance === undefined || i.nativeBalance === undefined) return i.balanceFailed ? { amount: null, reason: "unknown-balance" } : { amount, provisional: true }; const raw = i.parse(amount, i.quote.decimals); - if (i.quote.key === "eth") return raw + i.gasReserve > i.balance ? { amount: null, reason: "insufficient" } : { amount }; + if (i.quote.key === "eth") return raw + i.gasReserve > i.balance ? { amount: null, reason: "insufficient" } : { amount, provisional: false }; if (raw > i.balance) return { amount: null, reason: "insufficient" }; if (i.nativeBalance < i.gasReserve) return { amount: null, reason: "no-gas" }; - return { amount }; + return { amount, provisional: false }; }