From a3f7968e0c011d05cb74f453834445cfd8c92532 Mon Sep 17 00:00:00 2001 From: 0xward <0xward.dev@gmail.com> Date: Sun, 2 Aug 2026 16:49:31 +0000 Subject: [PATCH] No login screen inside MiniPay, not even for a frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OWNER, testing in MiniPay: *"masih sempet muncul halaman loginnya, meskipun sekedip langsung hilang, tetapi masih muncul, itu betulan mengganggu kedepannya jika game ku berhasil listing."* Right on both counts. A screen between the player and the game is what MiniPay's listing rules reject, and "only for a moment" is not a defence. The rule was never wrong; the TIMING was. `isMiniPay` is read from window.ethereum inside WagmiIsland, and lib/Web3Providers.tsx mounts that island on requestIdleCallback with a 1500ms ceiling. Until it mounts the bridge serves its defaults — `isMiniPay: false` — so shouldOfferSignIn() answered "yes, ask them" to a MiniPay player for up to a second and a half. The obvious fix was to hold the gate until `walletReady`. It was written, measured, and thrown away: inside MiniPay gate never appeared ✓ outside map at 50ms, gate at 1272ms ✗ A login landing on top of a map the player has been looking at for a second is the FIRST complaint wearing a different hat. So the wait is gone and the guarantee moved to where it costs nothing: privyGateFlag reads window.ethereum.isMiniPay directly, needs nothing mounted, and is therefore right on the very first render. It runs before the query-string override too — there is no argument for a URL that can force a login screen into MiniPay. A synchronous read has exactly one failure mode: a provider injected a moment late. So the flag is re-evaluated every 100ms for 1.5s, the same ceiling wagmi gets, and it can only ever flip the gate OFF. Measured after the change, polling every 30ms for 5 seconds against a build carrying a real app id: inside MiniPay 0 of 165 polls — never appeared outside appears, as it should Verified: tsc clean, build unchanged at 142/242 kB, 27/27 test:privy, check:copy clean, lint 35. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017A764RdnwpyWnG7uCNhMiQ --- components/game/GameFlowManager.tsx | 31 +++++++++++++++++- lib/privyGateFlag.ts | 49 ++++++++++++++++++++++++++++- scripts/test-privy-gate.js | 26 +++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/components/game/GameFlowManager.tsx b/components/game/GameFlowManager.tsx index 4466f40..75cf803 100644 --- a/components/game/GameFlowManager.tsx +++ b/components/game/GameFlowManager.tsx @@ -113,7 +113,7 @@ type GamePhase = 'menu' | 'sign-in' | 'username-setup' | 'character-select' | 'g * All player progress is stored ON-CHAIN via the contract. */ export default function GameFlowManager() { - const { address, isConnected, realAddress, isGuest, isMiniPay, connect } = useWallet() + const { address, isConnected, realAddress, isGuest, isMiniPay, walletReady, connect } = useWallet() const { playerProfile, isLoading: isLoadingProfile, @@ -535,6 +535,35 @@ export default function GameFlowManager() { // Skippable, per the owner's choice: a player who declines plays as a guest // exactly as before. Declining is remembered, so it asks once and not again. const [gateDone, setGateDone] = useState(false) + + // ── THE MINIPAY FLASH, AND WHY WAITING WAS THE WRONG FIX ────────────────── + // + // OWNER, testing inside MiniPay: *"masih sempet muncul halaman loginnya, + // meskipun sekedip langsung hilang."* He is right that it matters — a screen + // between the player and the game is exactly what MiniPay's listing rules + // reject, and "only for a moment" is not a defence. + // + // The cause was timing, not logic. `isMiniPay` is read from window.ethereum + // inside WagmiIsland, and lib/Web3Providers.tsx mounts that island on + // requestIdleCallback with a 1500ms ceiling — so the bridge served its + // default `isMiniPay: false` to a MiniPay player for up to a second and a + // half, and shouldOfferSignIn() answered "yes, ask them". + // + // The obvious fix — hold the gate until `walletReady` — was written, measured + // and thrown away. It works, but it costs the thing this screen exists for: + // + // inside MiniPay gate never appeared (0 of 165 polls over 5s) ✓ + // outside map at 50ms, gate at 1272ms ✗ + // + // A login that lands on top of a map the player has already been looking at + // for a second IS the bug he reported the first time. So the wait is gone and + // the guarantee moved into lib/privyGateFlag.ts, which reads MiniPay straight + // off window.ethereum and needs nothing mounted — correct on the very first + // render, and re-checked for a beat afterwards in case injection is late. + // + // `walletReady` stays in the destructure above deliberately unused by this + // rule: shouldOfferSignIn()'s own `!isMiniPay` is the third guard, and it + // arrives when wagmi does. const showPrivyGate = privyGateOn && !gateDone && shouldOfferSignIn() // The identity work itself lives in lib/useAccountActions — Settings needs diff --git a/lib/privyGateFlag.ts b/lib/privyGateFlag.ts index b161901..595dbf2 100644 --- a/lib/privyGateFlag.ts +++ b/lib/privyGateFlag.ts @@ -40,7 +40,33 @@ export function privyAppId(): string | null { return typeof id === 'string' && id.trim() ? id.trim() : null } +/** + * MiniPay, read straight off the injected provider. + * + * A DELIBERATE second copy of a rule that already lives at the call site, and + * safe to duplicate because it is one-way: it can only ever turn the gate OFF. + * If the two ever disagree, the disagreement costs a MiniPay player nothing and + * a non-MiniPay player one screen they were going to see anyway. + * + * It exists because the authoritative check goes through wagmi, and wagmi is + * mounted on an idle callback with a 1500ms ceiling (lib/Web3Providers.tsx). + * This one needs nothing mounted — MiniPay injects window.ethereum before the + * page's own scripts run — so it is right from the very first render, which is + * precisely the window in which the gate was flashing. + */ +function isMiniPayNow(): boolean { + if (typeof window === 'undefined') return false + try { + return !!(window as unknown as { ethereum?: { isMiniPay?: boolean } }).ethereum?.isMiniPay + } catch { + return false + } +} + export function readPrivyGateFlag(): boolean { + // Before anything else, including the query-string override. There is no + // argument for a URL that can force a login screen into MiniPay. + if (isMiniPayNow()) return false // No app id means Privy cannot initialise. Fall back rather than render a // login screen that can never log anyone in. if (!privyAppId()) return false @@ -65,6 +91,27 @@ export function readPrivyGateFlag(): boolean { */ export function usePrivyGateFlag(): boolean { const [on, setOn] = useState(false) - useEffect(() => { setOn(readPrivyGateFlag()) }, []) + useEffect(() => { + setOn(readPrivyGateFlag()) + // ── Re-checked for a beat, then never again ────────────────────────────── + // + // The gate renders on the first paint, which is the whole point: waiting + // for wagmi put the world map on screen 1.2s BEFORE the login, which is the + // bug this feature was reported for in the first place. + // + // Rendering that early means trusting a synchronous read of + // window.ethereum, and the one way that read can be wrong is a provider + // injected a moment late. So it is re-run for 1.5s — the same ceiling + // lib/Web3Providers.tsx gives wagmi — and any MiniPay that turns up inside + // that window switches the gate off within a frame instead of leaving it up + // until wagmi mounts. It only ever flips ON→OFF; nothing here can turn the + // gate on for somebody it was off for. + let n = 0 + const id = setInterval(() => { + if (!readPrivyGateFlag()) { setOn(false); clearInterval(id) } + if (++n >= 15) clearInterval(id) + }, 100) + return () => clearInterval(id) + }, []) return on } diff --git a/scripts/test-privy-gate.js b/scripts/test-privy-gate.js index f5b5459..a3059e7 100644 --- a/scripts/test-privy-gate.js +++ b/scripts/test-privy-gate.js @@ -65,6 +65,32 @@ ok('and the component refuses to mount the provider without one', // carrying a second condition, so `!isMiniPay` cannot drift out of it. ok('the gate is gated on shouldOfferSignIn(), not a second copy of the rule', /const showPrivyGate = privyGateOn && !gateDone && shouldOfferSignIn\(\)/.test(flow)) + +// ── THE MINIPAY FLASH ─────────────────────────────────────────────────────── +// +// The rule was right and the TIMING was wrong. `isMiniPay` comes from wagmi, +// and wagmi mounts on requestIdleCallback with a 1500ms ceiling — so inside +// MiniPay the bridge answered `isMiniPay: false` for up to a second and a half +// and the gate rendered. Owner: *"masih sempet muncul halaman loginnya."* +// +// Holding the gate until wagmi reported was written, measured and thrown away: +// it kept MiniPay clean but put the map on screen at 50ms and the login at +// 1272ms, which is the ORIGINAL complaint wearing a different hat. The +// guarantee lives in a synchronous read instead, so the gate still renders on +// the first paint. +ok('the gate does NOT wait for wagmi — that fix cost the placement', + !/gateArmed/.test(flow)) +ok('and the flag reads MiniPay straight off window.ethereum, needing nothing mounted', + /if \(isMiniPayNow\(\)\) return false/.test(flag) && + /ethereum\?: \{ isMiniPay\?: boolean \}/.test(flag)) +// Injection a moment late is the one way a synchronous read can be wrong, so +// the flag is re-read for the same 1500ms ceiling wagmi gets — one way only. +ok('and it keeps re-checking briefly, in case the provider is injected late', + /setInterval\(\(\) => \{/.test(flag) && /if \(!readPrivyGateFlag\(\)\) \{ setOn\(false\)/.test(flag)) + +// The query override must not be an escape hatch INTO MiniPay. +ok('and no ?privy=1 can force a login screen into MiniPay', + flag.indexOf('if (isMiniPayNow()) return false') < flag.indexOf("get('privy')")) ok('and shouldOfferSignIn still starts with !isMiniPay', /!isMiniPay && !realAddress && !getStoredAuthAddress\(\) && !hasSkippedSignIn\(\)/.test(flow)) ok('the SDK is dynamically imported, never in the initial chunk',