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: 30 additions & 1 deletion components/game/GameFlowManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion lib/privyGateFlag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
26 changes: 26 additions & 0 deletions scripts/test-privy-gate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading