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
5 changes: 5 additions & 0 deletions components/game/CraftingScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { GiAnvil, GiCheckedShield, GiSandsOfTime } from 'react-icons/gi'
import { useWallet, CELO_CHAIN_ID } from '@/lib/WalletProvider'
import WrongNetworkBar from './WrongNetworkBar'
import { pickBestPaymentToken } from '@/lib/constants/tokens'
import {
ACCEPTED_TOKENS, getMarketplaceItem, resolveItemId, maxWeaponTier, tokenLabel, TOKEN_LOGOS,
Expand Down Expand Up @@ -492,6 +493,10 @@ export default function CraftingScreen({ onBack, onGoToRun, address }: CraftingS
</button>
</header>

{/* Connected but on the wrong chain — see WrongNetworkBar. Renders
nothing at all on Celo, and cannot appear inside MiniPay. */}
<WrongNetworkBar />

{/* shard balance strip */}
<div className="mb-4 grid grid-cols-3 gap-2">
{(['t1', 't2', 't3'] as TierKey[]).map(k => (
Expand Down
5 changes: 5 additions & 0 deletions components/game/MarketplaceScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useState, useCallback, useRef } from 'react'
import { useWallet, CELO_CHAIN_ID } from '@/lib/WalletProvider'
import WrongNetworkBar from './WrongNetworkBar'
import { GiCrossedSwords, GiCheckedShield, GiMagnifyingGlass } from 'react-icons/gi'
import { pickBestPaymentToken, readStablecoinBalances } from '@/lib/constants/tokens'
import { MARKETPLACE_ITEMS, ACCEPTED_TOKENS, getMarketplaceItem, resolveItemId, tokenLabel, TOKEN_LOGOS, type MarketplaceItem, type MarketplaceTokenSymbol } from '@/lib/constants/marketplace'
Expand Down Expand Up @@ -364,6 +365,10 @@ export default function MarketplaceScreen({ onBack, address }: MarketplaceScreen
</button>
</header>

{/* Connected but on the wrong chain — see WrongNetworkBar. Renders
nothing at all on Celo, and cannot appear inside MiniPay. */}
<WrongNetworkBar />

{/* ARMORY TRIAL picker — shows once: Act 1 cleared + never granted.
Icon-first, two taps + one button, no paragraphs to read. */}
{act1Cleared && trialEligible && !isGuest && (
Expand Down
3 changes: 3 additions & 0 deletions components/game/SeasonPassScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { usePassSBT, SeasonInfo } from '@/hooks/usePassSBT'
import { getUserFriendlyError } from '@/lib/errorUtils'
import SeasonPassCard from './SeasonPassCard'
import { SEASON_IDS, currentSeasonId } from '@/lib/season'
import WrongNetworkBar from './WrongNetworkBar'

// SEASON_IDS / currentSeasonId moved to lib/season.ts — the world map shows
// pass status too now, and two copies of "which season is it" would drift.
Expand Down Expand Up @@ -184,6 +185,8 @@ export default function SeasonPassScreen({ onBack, address }: SeasonPassScreenPr
{/* Not "connect your wallet": inside MiniPay the address arrives with
no interaction, so a connect prompt is only ever shown to someone it
cannot help. Naming MiniPay is the actionable step. */}
<WrongNetworkBar />

{!address && (
<div className="mb-6 rounded border border-[#e8bd6f]/40 bg-[#e8bd6f]/10 p-3 text-sm text-[#f2cd82] font-mono">
Open NullState in MiniPay to mint a pass.
Expand Down
62 changes: 62 additions & 0 deletions components/game/WrongNetworkBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use client'

import { useCallback, useState } from 'react'
import { useWallet } from '@/lib/WalletProvider'

// ─── Connected, but not on Celo ──────────────────────────────────────────────
//
// OWNER, testing in the OKX in-app browser: *"posisi auto connect tapi gabisa
// mint pass dan buy ada tulisan 'wallet not connected'."*
//
// His wallet was connected. It was on OKX's default chain, and wagmi's
// getConnectorClient throws ConnectorChainMismatchError when the connector's
// chain is not the one asked for — so useWalletClient({chainId: CELO}) resolved
// to undefined and every payment reported a connection problem that did not
// exist.
//
// WagmiIsland now asks the wallet to switch the moment it lands on the wrong
// chain, which fixes it for anyone who taps Approve. This bar is for the rest:
// someone who declined, or a wallet that ignores the request. It says the true
// thing and gives them the one control that fixes it.
//
// Rendered only on the three screens that can actually spend. Not global,
// because a player who never opens the shop does not need to be told which
// chain the shop wants — and never inside MiniPay, which cannot reach this
// state at all.
//
// The copy deliberately avoids the phrase check:copy bans. It also names Celo
// rather than saying "wrong network", because "wrong" without a target is a
// complaint rather than an instruction.

export default function WrongNetworkBar() {
const { wrongNetwork, switchToCelo } = useWallet()
const [busy, setBusy] = useState(false)

const onSwitch = useCallback(async () => {
setBusy(true)
try {
await switchToCelo()
} finally {
// The wallet owns the prompt, so there is nothing to await past the
// request. Clearing shortly after keeps a declined prompt from leaving
// the button stuck.
setTimeout(() => setBusy(false), 1200)
}
}, [switchToCelo])

if (!wrongNetwork) return null

return (
<div className="mb-4 rounded border border-[#e8bd6f]/40 bg-[#e8bd6f]/10 p-3 font-mono text-sm text-[#f2cd82]">
<div>Your wallet is on another network. NullState runs on Celo.</div>
<button
type="button"
onClick={onSwitch}
disabled={busy}
className="mt-2 inline-block rounded border border-[#f2cd82]/60 px-3 py-1 text-xs uppercase tracking-wider text-[#f2cd82] disabled:opacity-50"
>
{busy ? 'Asking your wallet…' : 'Switch to Celo'}
</button>
</div>
)
}
12 changes: 10 additions & 2 deletions hooks/usePassSBT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function usePassSBT(walletAddress: string | undefined) {
// stablecoin, to TREASURY_WALLET (see lib/WalletProvider.tsx). Reused
// here for the v2.1 flexible-payment pass mint flow — see
// mintPaidPassFlexible below.
const { payToTreasury, publicClient, walletClient } = useWallet()
const { payToTreasury, publicClient, walletClient, wrongNetwork } = useWallet()

const [hasPass, setHasPass] = useState<boolean>(false)
const [passSeasonId, setPassSeasonId] = useState<bigint>(BigInt(0))
Expand Down Expand Up @@ -145,6 +145,14 @@ export function usePassSBT(walletAddress: string | undefined) {
// charged varies based on the wallet's balances.
const mintPaidPassFlexible = useCallback(
async (seasonId: bigint): Promise<{ success: boolean; mintTxHash: `0x${string}` }> => {
// The message the owner actually hit in the OKX browser. walletClient is
// undefined on any chain but Celo — wagmi's getConnectorClient throws
// ConnectorChainMismatchError, useWalletClient swallows it into
// `data: undefined` — so "not connected" was reported to someone whose
// wallet was connected and one tap from working. Name the real problem.
if (wrongNetwork) {
throw new Error('Wrong network — NullState runs on Celo. Switch your wallet to Celo and try again.')
}
if (!walletClient || !publicClient || !walletClient.account) {
throw new Error('Wallet not connected')
}
Expand Down Expand Up @@ -198,7 +206,7 @@ export function usePassSBT(walletAddress: string | undefined) {
setPayingToken(null)
}
},
[walletClient, publicClient, payToTreasury, fetchPassStatus],
[walletClient, publicClient, payToTreasury, fetchPassStatus, wrongNetwork],
)

useEffect(() => {
Expand Down
9 changes: 7 additions & 2 deletions hooks/useReward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function useReward(walletAddress: string | undefined) {
// WagmiProviderNotFoundError — which is exactly what it did: opening Rewards
// in MiniPay died with "a client-side exception has occurred". Both are null
// until wagmi has loaded, and every path below already guards on that.
const { publicClient, walletClient } = useWallet()
const { publicClient, walletClient, wrongNetwork } = useWallet()

const [seasonLeaderboard, setSeasonLeaderboard] = useState<SeasonLeaderboard | null>(null)
const [hasClaimedSeasonBonus, setHasClaimedSeasonBonus] = useState<boolean>(false)
Expand Down Expand Up @@ -98,6 +98,11 @@ export function useReward(walletAddress: string | undefined) {
// `account` is narrower on viem's generic WalletClient than on the one
// wagmi's useWalletClient() returned, so it is checked here rather than
// asserted — a client with no account cannot sign anyway.
// Same wrong-chain trap as the pass mint: on any chain but Celo the
// wallet client is undefined and this used to blame the connection.
if (wrongNetwork) {
throw new Error('Wrong network — NullState runs on Celo. Switch your wallet to Celo and try again.')
}
if (!walletClient?.account || !publicClient) throw new Error('Wallet not connected')
const account = walletClient.account

Expand Down Expand Up @@ -128,7 +133,7 @@ export function useReward(walletAddress: string | undefined) {
setIsLoading(false)
}
},
[walletClient, publicClient, currentSeason, checkSeasonBonusClaimed],
[walletClient, publicClient, currentSeason, checkSeasonBonusClaimed, wrongNetwork],
)

useEffect(() => {
Expand Down
76 changes: 68 additions & 8 deletions lib/WagmiIsland.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { celo, celoSepolia } from 'wagmi/chains'
import { celoTransport } from '@/lib/celoRpc'
import { injected } from 'wagmi/connectors'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { encodeFunctionData } from 'viem'
import { USDM_ADDRESS, USDM_ABI } from './contract-abi'
import { getUserFriendlyError, WalletFriendlyError, MINIPAY_ADD_CASH_URL } from './errorUtils'
Expand Down Expand Up @@ -86,7 +86,7 @@ export default function WagmiIsland() {
export function WagmiWalletIsland() {
const { address, isConnected, chain } = useAccount()
const { disconnect } = useDisconnect()
const { switchChain } = useSwitchChain()
const { switchChain, switchChainAsync } = useSwitchChain()
const publicClient = usePublicClient({ chainId: CELO_CHAIN_ID })
const { data: walletClient } = useWalletClient({ chainId: CELO_CHAIN_ID })
const { data: balanceData } = useBalance({ address, chainId: CELO_CHAIN_ID })
Expand Down Expand Up @@ -115,14 +115,60 @@ export function WagmiWalletIsland() {
if (injectedConnector) connect({ connector: injectedConnector, chainId: CELO_CHAIN_ID })
}, [isConnected, connect, connectors])

// ── AND ONTO CELO, IMMEDIATELY ───────────────────────────────────────────
//
// OWNER, testing in the OKX in-app browser: *"posisi auto connect tapi gabisa
// mint pass dan buy ada tulisan 'wallet not connected'."* The wallet was
// connected. It was on OKX's own default chain.
//
// That is fatal rather than cosmetic, because of one line inside wagmi
// (getConnectorClient.js):
//
// if (assertChainId && connectorChainId !== chainId)
// throw new ConnectorChainMismatchError(...)
//
// useWalletClient({chainId: CELO}) therefore resolves to undefined on any
// other chain, every payment hits its `!walletClient` guard, and the player
// is told their wallet is not connected. `switchToCelo` already existed for
// exactly this — plumbed through the bridge and never called from anywhere.
//
// Owner's decision, because MiniPay's reviewers may well test in a browser
// wallet: *"itu bener2 autoconnect dan autoswitch jaringan ke celo dr awal
// login dengan wallet."* So the switch is requested the moment a connection
// lands on the wrong chain, not deferred to the first purchase.
//
// Attempted ONCE per chain the wallet lands on. A player who declines is not
// asked again on every render — that is a popup loop, and it is worse than
// the bug. They get the banner and its button instead, and moving the wallet
// to another chain arms one fresh attempt.
const askedFor = useRef<number | null>(null)
const onWrongChain = isConnected && !!chain && chain.id !== CELO_CHAIN_ID
useEffect(() => {
if (!onWrongChain || !chain) return
if (askedFor.current === chain.id) return
askedFor.current = chain.id
try { switchChain({ chainId: CELO_CHAIN_ID }) } catch { /* declined or unsupported */ }
}, [onWrongChain, chain, switchChain])

const celoBalance = balanceData
? (Number(balanceData.value) / 10 ** balanceData.decimals).toFixed(2)
: '0.00'

// ── Send transaction helper ───────────────────────────────────────────────

// "Wallet not connected" was a lie on every wrong-chain failure, and it sent
// the owner looking for a connection problem that did not exist. One helper,
// so all three payment paths tell the same truth.
const assertReady = useCallback(() => {
if (onWrongChain) {
throw new Error('Wrong network — NullState runs on Celo. Switch your wallet to Celo and try again.')
}
if (!walletClient || !address) throw new Error('Wallet not connected')
}, [onWrongChain, walletClient, address])

const sendTx = useCallback(
async (data: `0x${string}`, value?: bigint): Promise<string> => {
assertReady()
if (!walletClient || !address) throw new Error('Wallet not connected')
setError(null)
setInsufficientFunds(false)
Expand Down Expand Up @@ -152,7 +198,7 @@ export function WagmiWalletIsland() {
throw new WalletFriendlyError(friendlyError, e)
}
},
[walletClient, publicClient, address]
[walletClient, publicClient, address, assertReady]
)

// ── Contract write functions ──────────────────────────────────────────────
Expand All @@ -171,6 +217,7 @@ export function WagmiWalletIsland() {
// directly to `toAddress` (the reward contract), NOT through NullState.sol.
const payUsdmFee = useCallback(
async (amountWei: bigint, toAddress: `0x${string}`): Promise<string> => {
assertReady()
if (!walletClient || !address) throw new Error('Wallet not connected')
if (typeof amountWei !== 'bigint' || amountWei <= BigInt(0)) {
throw new Error('Invalid USDm fee amount')
Expand Down Expand Up @@ -204,7 +251,7 @@ export function WagmiWalletIsland() {
throw new WalletFriendlyError(friendlyError, e)
}
},
[walletClient, publicClient, address]
[walletClient, publicClient, address, assertReady]
)

// ── Generic treasury payment ─────────────────────────────────────────────
Expand All @@ -218,6 +265,7 @@ export function WagmiWalletIsland() {
// under both names below so each call site reads clearly.
const payToTreasury = useCallback(
async (priceUsd: number, token: MarketplaceTokenSymbol): Promise<string> => {
assertReady()
if (!walletClient || !address) throw new Error('Wallet not connected')
const cfg = MARKETPLACE_TOKENS[token]
if (!cfg) throw new Error('Unsupported token')
Expand Down Expand Up @@ -249,7 +297,7 @@ export function WagmiWalletIsland() {
throw new WalletFriendlyError(friendlyError, e)
}
},
[walletClient, address]
[walletClient, address, assertReady]
)

// NOTE (removed 2026-07-12): `attackRaid`, `readPlayer`, and `readRaid`
Expand All @@ -264,15 +312,27 @@ export function WagmiWalletIsland() {
if (target) connect({ connector: target, chainId: CELO_CHAIN_ID })
}, [connect, connectors])

// The async variant, because the button in WrongNetworkBar shows a pending
// state and the fire-and-forget `switchChain` resolves instantly whether the
// wallet agreed or not — a spinner that means nothing. A decline is not an
// app error: the bar simply stays up, which is already the honest report.
const switchToCelo = useCallback(async () => {
try { switchChain({ chainId: CELO_CHAIN_ID }) } catch { /* user declined */ }
}, [switchChain])
try {
await switchChainAsync({ chainId: CELO_CHAIN_ID })
setError(null)
} catch {
// Wallets that cannot switch (some in-app browsers pin their chain) end
// up here. Saying so beats a button that silently does nothing.
setError('Could not switch automatically — please choose Celo in your wallet app.')
}
}, [switchChainAsync])

const value: WalletBridgeValue = useMemo(() => ({
ready: true,
address: address ?? null,
isConnected,
chainId: chain?.id ?? null,
wrongNetwork: onWrongChain,
isMiniPay,
celoBalance,
error,
Expand All @@ -285,7 +345,7 @@ export function WagmiWalletIsland() {
switchToCelo,
payUsdmFee,
payToTreasury,
}), [address, isConnected, chain?.id, isMiniPay, celoBalance, error, insufficientFunds,
}), [address, isConnected, chain?.id, isMiniPay, onWrongChain, celoBalance, error, insufficientFunds,
publicClient, walletClient, connectWallet, disconnect, switchToCelo, payUsdmFee, payToTreasury])

const publish = useWalletPublish()
Expand Down
4 changes: 4 additions & 0 deletions lib/WalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ export function useWallet() {
isConnecting: !w.ready,
walletReady: w.ready,
isMiniPay: w.isMiniPay,
// Connected, wrong chain. Screens that can spend need this because it is
// the difference between "you have no wallet" and "your wallet is one tap
// away from working" — and until now both reported the former.
wrongNetwork: w.wrongNetwork,
celoBalance: w.celoBalance,
error: w.error,
insufficientFunds: w.insufficientFunds,
Expand Down
14 changes: 14 additions & 0 deletions lib/walletBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ export interface WalletBridgeValue {
isConnected: boolean
chainId: number | null
isMiniPay: boolean
/**
* Connected, but the wallet is sitting on some other chain.
*
* NOT a cosmetic flag. wagmi's getConnectorClient throws
* ConnectorChainMismatchError when the connector's chain is not the one
* asked for, so useWalletClient({chainId: CELO}) resolves to undefined and
* every payment path fails its `!walletClient` guard — which used to report
* "Wallet not connected" to somebody whose wallet was perfectly connected.
* OKX and most browser wallets open on their own default chain, so this is
* the normal state for them until the switch lands. MiniPay is Celo-only and
* can never be in it.
*/
wrongNetwork: boolean
celoBalance: string
error: string | null
insufficientFunds: boolean
Expand Down Expand Up @@ -72,6 +85,7 @@ export const WALLET_BRIDGE_DEFAULT: WalletBridgeValue = {
isConnected: false,
chainId: null,
isMiniPay: false,
wrongNetwork: false,
celoBalance: '0.00',
error: null,
insufficientFunds: false,
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"test:exit-streak": "node scripts/test-exit-streak.js",
"test:privy": "node scripts/test-privy-gate.js --serve",
"test:kills": "node scripts/test-leaderboard-writes.js",
"test:proploot": "node scripts/test-prop-loot.js"
"test:proploot": "node scripts/test-prop-loot.js",
"test:network": "node scripts/test-wrong-network.js"
},
"overrides": {
"permissionless": {
Expand Down
Loading
Loading