Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions app/src/components/launchpad/LaunchForm.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Hex | null>(null);
// The metadataURI is keyed by meta_key, so findSalt can change the salt freely within one attempt. Both refs are
Expand All @@ -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);
Expand Down Expand Up @@ -716,7 +723,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = "
{initialBuyRaw && buyBalance !== undefined ? (
<span className="text-xs font-mono text-muted tnum">balance {fmtQuoteUnits(units(buyBalance, quote.decimals), quote.decimals)}</span>
) : null}
{initialBuyRaw ? <button type="button" onClick={declineFirstBuy} className={btn.secondarySm}>No first buy</button> : null}
{initialBuyRaw ? <button type="button" onClick={() => declineFirstBuy(true)} className={btn.secondarySm}>No first buy</button> : null}
</div>
{buyPreview ? (
<p className="text-sm text-body">
Expand All @@ -728,8 +735,10 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = "
<p className={helper}>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.</p>
) : suggestion.reason === "no-gas" ? (
<p className={helper}>Suggested {fmtQuoteUnits(Number(defaultFirstBuy(quote)), quote.decimals)} {quote.symbol}, but this wallet has no ETH left for the buy&apos;s gas. The launch stays free; you can buy on the token page later.</p>
) : suggestion.reason === "no-wallet" ? (
<p className={helper}>Connect a wallet on {CHAIN_LABEL} to see the suggested amount.</p>
) : suggestion.reason === "unknown-balance" ? (
<p className={helper}>Could not read your balance, so nothing is suggested. The launch stays free; you can still type an amount.</p>
) : suggestion.reason === "declined" && !typedBuy ? (
<p className={helper}>No first buy. The launch stays free.{defaultFirstBuy(quote) ? <> <button type="button" onClick={suggestAgain} className="font-medium text-brand underline underline-offset-4 hover:text-ink">Suggest {fmtQuoteUnits(Number(defaultFirstBuy(quote)), quote.decimals)} {quote.symbol} again</button></> : null}</p>
) : null}
<p className={helper}>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.</p>
<p className={helper}>Other traders can buy before you. First-buy slippage tolerance: {FIRST_BUY_SLIPPAGE_BPS / 100}%. Network gas and pool fees apply.</p>
Expand Down
11 changes: 10 additions & 1 deletion app/src/components/launchpad/launch-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ test("launch merge retains chain-scoped marks and honest optional-buy copy", ()
assert.match(source, /<TokenAvatar chain=\{chain\}/);
assert.match(source, /label=\{initialBuyRaw \? "Launch \+ first buy" : "Launch for free, gas only"\}/);
// a suggested buy is computed from a known, sufficient balance and can be cleared; a typed amount keeps the strict checks
assert.match(source, /suggestFirstBuy\(\{ quote, connected: Boolean\(address\) && onChain, balance: buyBalance, nativeBalance, gasReserve: GAS_RESERVE_WEI, declined: buyDeclined \|\| Boolean\(typedBuy\)/);
assert.match(source, /suggestFirstBuy\(\{ quote, connected: Boolean\(address\) && onChain, balance: buyBalance, nativeBalance, balanceFailed: buyBalanceFailed \|\| ethBal\.isError, gasReserve: GAS_RESERVE_WEI, declined: buyDeclined \|\| Boolean\(typedBuy\)/);
// an ERC-20 quote's typed buy is checked for native gas too, not only the token balance
assert.match(source, /initialBuyRaw && quote\.key !== "eth" && nativeBalance !== undefined && nativeBalance < GAS_RESERVE_WEI/);
assert.match(source, /const initialBuy = typedBuy \|\| suggestion\.amount \|\| ""/);
Expand All @@ -39,6 +39,15 @@ test("launch merge retains chain-scoped marks and honest optional-buy copy", ()
assert.match(source, /setTypedBuyFor\(\{ amount: v, quoteId \}\)/);
assert.match(source, /No first buy/);
assert.match(source, /Clear it and the launch stays free/);
// a cleared suggestion is visible and reversible; only the explicit button persists for the session
assert.match(source, /suggestion\.reason === "declined" && !typedBuy/);
assert.match(source, /onClick=\{suggestAgain\}/);
assert.match(source, /onClick=\{\(\) => 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/);
Expand Down
53 changes: 53 additions & 0 deletions app/src/lib/launchpad/first-buy-session.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> } {
const store = new Map<string, string>();
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<string, string> };
}

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<typeof fakeStorage>).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);
}
});
44 changes: 44 additions & 0 deletions app/src/lib/launchpad/first-buy-session.ts
Original file line number Diff line number Diff line change
@@ -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();
}
24 changes: 13 additions & 11 deletions app/src/lib/launchpad/first-buy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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 });
});
Loading
Loading