diff --git a/components/game/CraftingScreen.tsx b/components/game/CraftingScreen.tsx index 0770819..ae7254b 100644 --- a/components/game/CraftingScreen.tsx +++ b/components/game/CraftingScreen.tsx @@ -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, @@ -492,6 +493,10 @@ export default function CraftingScreen({ onBack, onGoToRun, address }: CraftingS + {/* Connected but on the wrong chain — see WrongNetworkBar. Renders + nothing at all on Celo, and cannot appear inside MiniPay. */} + + {/* shard balance strip */}
{(['t1', 't2', 't3'] as TierKey[]).map(k => ( diff --git a/components/game/MarketplaceScreen.tsx b/components/game/MarketplaceScreen.tsx index 524f5e1..ab2282f 100644 --- a/components/game/MarketplaceScreen.tsx +++ b/components/game/MarketplaceScreen.tsx @@ -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' @@ -364,6 +365,10 @@ export default function MarketplaceScreen({ onBack, address }: MarketplaceScreen + {/* Connected but on the wrong chain — see WrongNetworkBar. Renders + nothing at all on Celo, and cannot appear inside MiniPay. */} + + {/* ARMORY TRIAL picker — shows once: Act 1 cleared + never granted. Icon-first, two taps + one button, no paragraphs to read. */} {act1Cleared && trialEligible && !isGuest && ( diff --git a/components/game/SeasonPassScreen.tsx b/components/game/SeasonPassScreen.tsx index 3942304..84378ad 100644 --- a/components/game/SeasonPassScreen.tsx +++ b/components/game/SeasonPassScreen.tsx @@ -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. @@ -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. */} + + {!address && (
Open NullState in MiniPay to mint a pass. diff --git a/components/game/WrongNetworkBar.tsx b/components/game/WrongNetworkBar.tsx new file mode 100644 index 0000000..775e217 --- /dev/null +++ b/components/game/WrongNetworkBar.tsx @@ -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 ( +
+
Your wallet is on another network. NullState runs on Celo.
+ +
+ ) +} diff --git a/hooks/usePassSBT.ts b/hooks/usePassSBT.ts index 6300cd1..4d0da60 100644 --- a/hooks/usePassSBT.ts +++ b/hooks/usePassSBT.ts @@ -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(false) const [passSeasonId, setPassSeasonId] = useState(BigInt(0)) @@ -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') } @@ -198,7 +206,7 @@ export function usePassSBT(walletAddress: string | undefined) { setPayingToken(null) } }, - [walletClient, publicClient, payToTreasury, fetchPassStatus], + [walletClient, publicClient, payToTreasury, fetchPassStatus, wrongNetwork], ) useEffect(() => { diff --git a/hooks/useReward.ts b/hooks/useReward.ts index fd8304e..c2e0388 100644 --- a/hooks/useReward.ts +++ b/hooks/useReward.ts @@ -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(null) const [hasClaimedSeasonBonus, setHasClaimedSeasonBonus] = useState(false) @@ -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 @@ -128,7 +133,7 @@ export function useReward(walletAddress: string | undefined) { setIsLoading(false) } }, - [walletClient, publicClient, currentSeason, checkSeasonBonusClaimed], + [walletClient, publicClient, currentSeason, checkSeasonBonusClaimed, wrongNetwork], ) useEffect(() => { diff --git a/lib/WagmiIsland.tsx b/lib/WagmiIsland.tsx index 2bbb0e3..b9090c0 100644 --- a/lib/WagmiIsland.tsx +++ b/lib/WagmiIsland.tsx @@ -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' @@ -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 }) @@ -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(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 => { + assertReady() if (!walletClient || !address) throw new Error('Wallet not connected') setError(null) setInsufficientFunds(false) @@ -152,7 +198,7 @@ export function WagmiWalletIsland() { throw new WalletFriendlyError(friendlyError, e) } }, - [walletClient, publicClient, address] + [walletClient, publicClient, address, assertReady] ) // ── Contract write functions ────────────────────────────────────────────── @@ -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 => { + assertReady() if (!walletClient || !address) throw new Error('Wallet not connected') if (typeof amountWei !== 'bigint' || amountWei <= BigInt(0)) { throw new Error('Invalid USDm fee amount') @@ -204,7 +251,7 @@ export function WagmiWalletIsland() { throw new WalletFriendlyError(friendlyError, e) } }, - [walletClient, publicClient, address] + [walletClient, publicClient, address, assertReady] ) // ── Generic treasury payment ───────────────────────────────────────────── @@ -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 => { + assertReady() if (!walletClient || !address) throw new Error('Wallet not connected') const cfg = MARKETPLACE_TOKENS[token] if (!cfg) throw new Error('Unsupported token') @@ -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` @@ -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, @@ -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() diff --git a/lib/WalletProvider.tsx b/lib/WalletProvider.tsx index 3ffd11c..f4792ab 100644 --- a/lib/WalletProvider.tsx +++ b/lib/WalletProvider.tsx @@ -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, diff --git a/lib/walletBridge.tsx b/lib/walletBridge.tsx index 9db0e51..e2b2ad6 100644 --- a/lib/walletBridge.tsx +++ b/lib/walletBridge.tsx @@ -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 @@ -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, diff --git a/package.json b/package.json index de72820..9ddd7e0 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/test-wrong-network.js b/scripts/test-wrong-network.js new file mode 100644 index 0000000..b5cecca --- /dev/null +++ b/scripts/test-wrong-network.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * test-wrong-network.js — "Wallet not connected" was a lie. + * + * OWNER, testing in the OKX in-app browser: *"posisi auto connect tapi gabisa + * mint pass dan buy ada tulisan 'wallet not connected', itu kenapa?"* + * + * His wallet was connected. It was on OKX's default chain, and this line lives + * inside wagmi (@wagmi/core/actions/getConnectorClient): + * + * if (assertChainId && connectorChainId !== chainId) + * throw new ConnectorChainMismatchError(...) + * + * So useWalletClient({chainId: CELO_CHAIN_ID}) resolves to undefined on ANY + * other chain, every payment path hit its `!walletClient` guard, and the player + * was told they had no wallet. MiniPay is Celo-only and never saw it, which is + * why it survived to the first OKX test. + * + * The nastiest part was already-written-and-never-called: `switchToCelo` had + * been plumbed through the bridge and had no caller anywhere in the app. + * + * These are source-level assertions rather than a browser run, deliberately. + * Reproducing this needs a wallet that connects on a non-Celo chain, and the + * sandbox has no wallet at all — so what can honestly be checked is that the + * guarantees exist, are wired, and cannot quietly come undone. + * + * node scripts/test-wrong-network.js + */ +const fs = require('fs'), path = require('path') + +let fails = 0 +const ok = (l, c, d) => { console.log((c ? ' ✓ ' : ' ✗ FAIL: ') + l + (d !== undefined ? ' (' + d + ')' : '')); if (!c) fails++ } +const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8') + +const island = read('lib/WagmiIsland.tsx') +const bridge = read('lib/walletBridge.tsx') +const provider = read('lib/WalletProvider.tsx') +const bar = read('components/game/WrongNetworkBar.tsx') + +// ── the upstream rule this whole file exists because of ───────────────────── +// Asserted against wagmi's own source, so a future upgrade that changes the +// behaviour shows up here rather than in a player's failed purchase. +const wagmi = fs.readFileSync( + path.join(__dirname, '..', 'node_modules/@wagmi/core/dist/esm/actions/getConnectorClient.js'), 'utf8') +ok('wagmi still throws when the connector is on another chain', + /assertChainId && connectorChainId !== chainId/.test(wagmi) && + /ConnectorChainMismatchError/.test(wagmi)) +ok('and the app still asks for a Celo-pinned wallet client, which is what trips it', + /useWalletClient\(\{ chainId: CELO_CHAIN_ID \}\)/.test(island)) + +// ── the fix: ask, once per chain ──────────────────────────────────────────── +ok('a connection on the wrong chain triggers a switch', + /const onWrongChain = isConnected && !!chain && chain\.id !== CELO_CHAIN_ID/.test(island) && + /switchChain\(\{ chainId: CELO_CHAIN_ID \}\)/.test(island)) +// Asking on every render is a popup loop, which is worse than the bug it fixes. +ok('and only ONCE per chain, never in a render loop', + /askedFor = useRef\(null\)/.test(island) && + /if \(askedFor\.current === chain\.id\) return/.test(island) && + /askedFor\.current = chain\.id/.test(island)) + +// ── the message stops lying ───────────────────────────────────────────────── +// sendTx, payUsdmFee and payToTreasury — every path in the island that signs. +ok('all three payment paths in the island check the network first', + (island.match(/^\s+assertReady\(\)$/gm) || []).length === 3, + String((island.match(/^\s+assertReady\(\)$/gm) || []).length)) +ok('and the message names Celo instead of claiming there is no wallet', + /Wrong network — NullState runs on Celo/.test(island)) + +// The two hooks that guard on walletClient THEMSELVES rather than going +// through the island — the pass mint is the exact screen the owner reported, +// and the reward claim has the identical shape. +for (const [hook, file] of [['the Season Pass mint', 'hooks/usePassSBT.ts'], + ['the reward claim', 'hooks/useReward.ts']]) { + const src = read(file) + ok(`${hook} reports the network too, not a missing wallet`, + /if \(wrongNetwork\) \{/.test(src) && /Wrong network — NullState runs on Celo/.test(src)) + ok(` …and re-runs when the chain changes`, /wrongNetwork\]/.test(src) || /wrongNetwork,/.test(src)) +} + +// ── the escape hatch is finally wired ─────────────────────────────────────── +// switchToCelo existed, was plumbed through the bridge, and had no caller. +ok('switchToCelo is awaited, so the button can show a truthful pending state', + /await switchChainAsync\(\{ chainId: CELO_CHAIN_ID \}\)/.test(island)) +ok('and something actually calls it now', + /switchToCelo\(\)/.test(bar), 'WrongNetworkBar') + +// ── the flag reaches the screens that can spend ───────────────────────────── +ok('wrongNetwork is on the bridge', /wrongNetwork: boolean/.test(bridge)) +ok('defaulted false, so nothing shows before wagmi mounts', /wrongNetwork: false,/.test(bridge)) +ok('and published through WalletProvider', /wrongNetwork: w\.wrongNetwork,/.test(provider)) + +for (const screen of ['MarketplaceScreen', 'CraftingScreen', 'SeasonPassScreen']) { + const src = read(`components/game/${screen}.tsx`) + ok(`${screen} shows the bar`, //.test(src) && + /import WrongNetworkBar from '\.\/WrongNetworkBar'/.test(src)) +} + +// ── and it cannot become a screen MiniPay would reject ────────────────────── +// MiniPay is Celo-only, so wrongNetwork is unreachable there — but the copy +// rule is checked anyway, because that is the check that would catch a future +// edit adding the banned phrase to this file. +ok('the bar never uses the phrase MiniPay bans', + !/connect\s+(a|an|the|your|my)?\s*wallet/i.test(bar)) +ok('and it renders nothing at all when the network is right', + /if \(!wrongNetwork\) return null/.test(bar)) + +console.log(fails ? ` ${fails} GAGAL` : ' semua lolos') +console.log(' NOTE: a real wrong-chain wallet is NOT simulated — the sandbox has no wallet.') +process.exit(fails ? 1 : 0)