diff --git a/components/game/PrivySignerIsland.tsx b/components/game/PrivySignerIsland.tsx
new file mode 100644
index 0000000..2f676f8
--- /dev/null
+++ b/components/game/PrivySignerIsland.tsx
@@ -0,0 +1,48 @@
+'use client'
+
+import { PrivyProvider } from '@privy-io/react-auth'
+import { celo } from 'viem/chains'
+import { privyAppId } from '@/lib/privyGateFlag'
+import PrivySignerPublisher from './PrivySignerPublisher'
+
+// ─── The one PrivyProvider that outlives the login screen ────────────────────
+//
+// OWNER: *"harus bisa pembayaran juga lewat privy."*
+//
+// The gate screen is short-lived — it unmounts the moment the player is signed
+// in — so a provider that lived only inside it could not sign anything an hour
+// later at the shop. This mounts beside the app (lib/Web3Providers.tsx) and
+// stays for the session.
+//
+// It renders NO UI at all. Everything it knows reaches the rest of the app
+// through lib/privySignerBridge, which is why nothing remounts when it arrives
+// a second after the map.
+//
+// The config is deliberately the same object shape PrivyGate uses. Both are
+// pinned to Celo — `defaultChain` decides where an embedded wallet is CREATED,
+// `supportedChains` is what an external wallet is asked to be on — and both use
+// the same `celo` from viem/chains that lib/WagmiIsland.tsx gives wagmi, so
+// there is exactly one idea of which network this game runs on.
+
+export default function PrivySignerIsland() {
+ const appId = privyAppId()
+ // No credential, no provider. The gate treats a missing app id as "off"
+ // rather than rendering a login that cannot complete; the same rule has to
+ // hold here, or Privy throws on mount for a deployment that set the flag and
+ // forgot the id.
+ if (!appId) return null
+ return (
+
+
+
+ )
+}
diff --git a/components/game/PrivySignerPublisher.tsx b/components/game/PrivySignerPublisher.tsx
new file mode 100644
index 0000000..a7a45ef
--- /dev/null
+++ b/components/game/PrivySignerPublisher.tsx
@@ -0,0 +1,113 @@
+'use client'
+
+import { useEffect } from 'react'
+import { useWallets, getEmbeddedConnectedWallet } from '@privy-io/react-auth'
+import { createWalletClient, custom, encodeFunctionData } from 'viem'
+import { celo } from 'viem/chains'
+import { usePrivySignerPublish, PRIVY_SIGNER_DEFAULT } from '@/lib/privySignerBridge'
+import { USDM_ABI } from '@/lib/contract-abi'
+import { withAttribution } from '@/lib/attribution-tag'
+import {
+ MARKETPLACE_TOKENS, TREASURY_WALLET, parseTokenAmount, getFeeCurrency,
+ type MarketplaceTokenSymbol,
+} from '@/lib/constants/tokens'
+
+// ─── Turning a Privy embedded wallet into something that can pay ─────────────
+//
+// Renders nothing. It lives inside PrivyProvider, reads the embedded wallet,
+// builds a viem WalletClient from its EIP-1193 provider, and publishes it into
+// lib/privySignerBridge so screens outside the provider can spend from it.
+//
+// ── WHY viem DIRECTLY, RATHER THAN @privy-io/wagmi ──────────────────────────
+//
+// Chessify uses @privy-io/wagmi, which replaces wagmi's own WagmiProvider so
+// Privy wallets appear as connectors. That is the right call in an app with ONE
+// provider tree. This app has two on purpose: lib/WagmiIsland.tsx serves MiniPay
+// and browser wallets and must keep working with zero Privy code loaded, and
+// mounting a second, competing WagmiProvider beside it is how you get two
+// sources of truth for "which wallet is connected".
+//
+// A viem client built straight from the provider avoids that entirely. It is
+// also less code: everything downstream — payToTreasury, usePassSBT,
+// useReward — already takes a WalletClient and does not care where it came
+// from.
+//
+// ── CELO, ASSERTED RATHER THAN ASSUMED ──────────────────────────────────────
+//
+// The provider is pinned to Celo, and the wallet is asked to switch first if it
+// is anywhere else. Privy's own docs are explicit that switchChain does NOT
+// update providers already handed out, so the provider is requested AFTER the
+// switch — asking in the other order returns a client that still signs for the
+// old chain, which is exactly the failure the owner hit in the OKX browser.
+
+export default function PrivySignerPublisher() {
+ const { wallets, ready } = useWallets()
+ const publish = usePrivySignerPublish()
+
+ useEffect(() => {
+ let cancelled = false
+ if (!ready) { publish(PRIVY_SIGNER_DEFAULT); return }
+
+ const wallet = getEmbeddedConnectedWallet(wallets) ?? wallets[0] ?? null
+ if (!wallet) { publish({ ...PRIVY_SIGNER_DEFAULT, ready: true }); return }
+
+ ;(async () => {
+ try {
+ // Order matters — see the note above. Switch, THEN take the provider.
+ if (wallet.chainId !== `eip155:${celo.id}`) {
+ await wallet.switchChain(celo.id)
+ }
+ const provider = await wallet.getEthereumProvider()
+ if (cancelled) return
+ const address = wallet.address as `0x${string}`
+ const walletClient = createWalletClient({
+ account: address,
+ chain: celo,
+ transport: custom(provider),
+ })
+ // The same transfer WagmiIsland's payToTreasury builds, from the same
+ // constants — one treasury address, one token table, one attribution
+ // tag. Only the signer differs, which is the entire point.
+ //
+ // feeCurrency is what makes this work without a smart wallet: CIP-64
+ // pays gas in the stablecoin being spent, so a Google player holding
+ // USDT and no CELO can still buy. getFeeCurrency, not the raw token
+ // address — USDC and USDT go through a fee adapter per MiniPay's docs,
+ // and passing the token itself is wrong for those two.
+ const payToTreasury = async (priceUsd: number, token: string): Promise => {
+ const sym = token as MarketplaceTokenSymbol
+ const cfg = MARKETPLACE_TOKENS[sym]
+ if (!cfg) throw new Error('Unsupported token')
+ const amountWei = parseTokenAmount(String(priceUsd), sym)
+ if (amountWei <= BigInt(0)) throw new Error('Invalid price')
+ const data = encodeFunctionData({
+ abi: USDM_ABI,
+ functionName: 'transfer',
+ args: [TREASURY_WALLET as `0x${string}`, amountWei],
+ })
+ return walletClient.sendTransaction({
+ account: address,
+ chain: celo,
+ to: cfg.address as `0x${string}`,
+ data: withAttribution(data),
+ value: BigInt(0),
+ feeCurrency: getFeeCurrency(sym),
+ })
+ }
+ publish({ ready: true, address, walletClient, payToTreasury })
+ } catch {
+ // A wallet that will not move to Celo cannot pay here, and saying so by
+ // publishing nothing is better than publishing a client that fails on
+ // every send. The player keeps their account and their progress either
+ // way — this only ever gates spending.
+ if (!cancelled) publish({ ...PRIVY_SIGNER_DEFAULT, ready: true })
+ }
+ })()
+
+ return () => { cancelled = true }
+ // wallets is a new array identity on most renders, so the effect keys on
+ // the two things that actually change the answer.
+ }, [ready, wallets, publish])
+
+ return null
+}
diff --git a/lib/WalletProvider.tsx b/lib/WalletProvider.tsx
index f4792ab..83b4a5e 100644
--- a/lib/WalletProvider.tsx
+++ b/lib/WalletProvider.tsx
@@ -14,6 +14,7 @@ import type { MarketplaceTokenSymbol } from './constants/tokens'
import { GUEST_STORAGE_KEY, generateAutoUsername } from './guestIdentity'
import { writeCachedProfile } from './profileCache'
import { getStoredAuthAddress } from './authIdentity'
+import { usePrivySigner } from './privySignerBridge'
// ─── Contract config ─────────────────────────────────────────────────────────
@@ -128,7 +129,9 @@ export function useWallet() {
// The guest id is only minted when the first two are absent — otherwise
// signing in would leave a stray guest identity behind it, and the next
// sign-out would land the player on a stranger's progress.
+ const privy = usePrivySigner()
const realAddress = w.address ?? null
+ const pay = w.walletClient ? w.payToTreasury : (privy.payToTreasury ?? w.payToTreasury)
const authAddress = realAddress ? null : getStoredAuthAddress()
const guestAddress = realAddress || authAddress ? null : getGuestAddress()
const isGuest = !realAddress && !!guestAddress
@@ -155,13 +158,29 @@ export function useWallet() {
insufficientFunds: w.insufficientFunds,
addCashUrl: w.addCashUrl,
publicClient: w.publicClient,
- walletClient: w.walletClient,
+ // ── WHICH WALLET SIGNS ────────────────────────────────────────────────
+ //
+ // wagmi's, whenever there is one. A player who connected MiniPay or a
+ // browser wallet has told us which wallet they mean, and that answer
+ // outranks an embedded wallet they may not even know they have.
+ //
+ // Privy's only when wagmi has none. That is the Google/email player, whose
+ // embedded wallet is the only thing they can sign with.
+ //
+ // Note what does NOT change: `address` above. The save is keyed on the
+ // account address derived in lib/authIdentity.ts, and re-keying it onto the
+ // embedded wallet would orphan every document already stored under it. The
+ // embedded wallet is where money comes FROM; it is not who the player IS.
+ walletClient: w.walletClient ?? privy.walletClient,
connect: w.connect,
disconnect: w.disconnect,
switchToCelo: w.switchToCelo,
payUsdmFee: w.payUsdmFee,
- buyMarketplaceItem: w.payToTreasury, // kept for MarketplaceScreen.tsx, unchanged behavior
- payToTreasury: w.payToTreasury,
+ // Same precedence, same reason. `pay` resolves to wagmi's payToTreasury
+ // unless there is no wagmi wallet and Privy has published one of its own —
+ // implemented in the lazy island so viem never reaches this module.
+ buyMarketplaceItem: pay, // kept for MarketplaceScreen.tsx, unchanged behavior
+ payToTreasury: pay,
}
}
diff --git a/lib/Web3Providers.tsx b/lib/Web3Providers.tsx
index 45eb726..1c4aa7d 100644
--- a/lib/Web3Providers.tsx
+++ b/lib/Web3Providers.tsx
@@ -31,11 +31,29 @@ import { ReactNode, useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
import WalletProvider from '@/lib/WalletProvider'
import { WalletBridgeProvider } from '@/lib/walletBridge'
+import { PrivySignerBridgeProvider } from '@/lib/privySignerBridge'
+import { readPrivyGateFlag } from '@/lib/privyGateFlag'
// ssr:false because a wallet cannot exist on the server; it is also what keeps
// the wagmi chunk out of the server-rendered payload entirely.
const WagmiIsland = dynamic(() => import('@/lib/WagmiIsland'), { ssr: false })
+// ─── The Privy signer island ────────────────────────────────────────────────
+//
+// Beside the app, never around it — the same rule WagmiIsland follows, and for
+// the same reason: mounting a provider around the tree later would remount
+// every child and lose their state.
+//
+// Loaded ONLY when readPrivyGateFlag() says yes, which is false inside MiniPay,
+// false with no app id, and false by default. So a MiniPay player downloads
+// none of this, which is the whole reason the app does not simply wrap itself
+// in PrivyProvider the way Chessify does.
+//
+// It renders no UI. Its only job is to keep a Privy signer alive for the rest
+// of the session, so a player who signed in with Google an hour ago can still
+// pay at the shop — the gate screen itself is short-lived and cannot hold it.
+const PrivySignerIsland = dynamic(() => import('@/components/game/PrivySignerIsland'), { ssr: false })
+
const IDLE_CEILING_MS = 1500
function DeferredWagmi() {
@@ -60,11 +78,24 @@ function DeferredWagmi() {
return mount ? : null
}
+function DeferredPrivySigner() {
+ const [mount, setMount] = useState(false)
+ useEffect(() => {
+ // Read in an effect, not during render: the flag consults window.ethereum
+ // and the query string, and /game is prerendered.
+ if (readPrivyGateFlag()) setMount(true)
+ }, [])
+ return mount ? : null
+}
+
export default function Web3Providers({ children }: { children: ReactNode }) {
return (
- {children}
-
+
+ {children}
+
+
+
)
}
diff --git a/lib/privySignerBridge.tsx b/lib/privySignerBridge.tsx
new file mode 100644
index 0000000..46f577b
--- /dev/null
+++ b/lib/privySignerBridge.tsx
@@ -0,0 +1,100 @@
+'use client'
+
+import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
+import type { Account, Transport, WalletClient } from 'viem'
+import type { Chain as CeloChain } from 'viem'
+
+// ─── The Privy signer, published rather than wrapped ─────────────────────────
+//
+// OWNER: *"kedepannya aku gatau bakal ada org yg buka game ku lewat browser dan
+// konek privy, jadi harus bisa pembayaran juga lewat privy."*
+//
+// The obvious way to do that is what Chessify does — wrap the whole app in
+// PrivyProvider (see src/app/providers.tsx in jadonamite/playchessify). We
+// cannot: that ships the Privy SDK to every MiniPay player, for a screen they
+// are never shown, on the connections least able to afford the download. It is
+// the same reason lib/walletBridge.tsx exists for wagmi.
+//
+// So this is walletBridge's shape again, one layer over: a context that is
+// ALWAYS mounted and costs nothing (no Privy import anywhere in this file), and
+// an island that mounts the SDK beside the app and publishes into it. Nothing
+// remounts when Privy arrives a second later, and with the gate off nothing is
+// downloaded at all.
+//
+// ── WHAT IS PUBLISHED, AND WHAT IS DELIBERATELY NOT ─────────────────────────
+//
+// A signer. Not an identity.
+//
+// The player's save is keyed on the address in lib/authIdentity.ts — SHA-256 of
+// their Privy id — and that must not change just because they now also have an
+// embedded wallet: re-keying would orphan every document already stored under
+// the old address. So the embedded wallet appears here as somewhere money can
+// come FROM, while `address` in WalletProvider keeps meaning who the player IS.
+//
+// ── AND WHY THERE IS NO SMART WALLET HERE ───────────────────────────────────
+//
+// Chessify pairs Privy with ERC-4337 and a Pimlico bundler so social players
+// never need gas. On Celo that is a solution to a problem the chain already
+// solved: CIP-64 lets gas be paid in a stablecoin, and this app already does it
+// (pickBestFeeCurrency in lib/constants/tokens.ts). A player holding only USDT
+// can pay for a $1 item with no CELO, no bundler, no sponsor, and no extra
+// service to keep funded. Owner's call: *"lanjut pakai CIP-64 aja, jangan smart
+// wallet."*
+
+export interface PrivySignerValue {
+ /** True once the island has mounted and Privy has settled. */
+ ready: boolean
+ /**
+ * The embedded wallet's address — a REAL key the player controls, and the
+ * only address here that can sign. Null when the player has no Privy wallet.
+ */
+ address: `0x${string}` | null
+ /** Signing client for that wallet, pinned to Celo. Null until it exists. */
+ walletClient: WalletClient | null
+ /**
+ * Send `priceUsd` of a stablecoin to the treasury from the embedded wallet.
+ *
+ * Mirrors the bridge's own payToTreasury so the shop can call one shape and
+ * not care which wallet is paying. Implemented inside the LAZY island rather
+ * than here on purpose: doing the encoding in this file would pull viem into
+ * the main bundle, which is the exact cost lib/WagmiIsland.tsx exists to
+ * avoid — and a MiniPay player must not pay for a code path they can never
+ * reach.
+ *
+ * Gas is paid in the same stablecoin (CIP-64), so a player holding only USDT
+ * needs no CELO. That is why this needs no smart wallet and no sponsor.
+ */
+ payToTreasury: ((priceUsd: number, token: string) => Promise) | null
+}
+
+export const PRIVY_SIGNER_DEFAULT: PrivySignerValue = {
+ ready: false,
+ address: null,
+ walletClient: null,
+ payToTreasury: null,
+}
+
+const ValueContext = createContext(PRIVY_SIGNER_DEFAULT)
+const PublishContext = createContext<(v: PrivySignerValue) => void>(() => {})
+
+/** Read the Privy signer. Safe anywhere — returns the default until it exists. */
+export function usePrivySigner(): PrivySignerValue {
+ return useContext(ValueContext)
+}
+
+/** The island's end of the bridge. */
+export function usePrivySignerPublish() {
+ return useContext(PublishContext)
+}
+
+export function PrivySignerBridgeProvider({ children }: { children: ReactNode }) {
+ const [value, setValue] = useState(PRIVY_SIGNER_DEFAULT)
+ // The publisher is stable, so the island's effect does not re-fire on every
+ // render of this provider — the same guarantee walletBridge makes.
+ const publish = useMemo(() => setValue, [])
+ return (
+
+ {children}
+
+ )
+}
diff --git a/package.json b/package.json
index 9ddd7e0..2ed872e 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,8 @@
"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:network": "node scripts/test-wrong-network.js"
+ "test:network": "node scripts/test-wrong-network.js",
+ "test:privypay": "node scripts/test-privy-pay.js"
},
"overrides": {
"permissionless": {
diff --git a/scripts/test-privy-pay.js b/scripts/test-privy-pay.js
new file mode 100644
index 0000000..cf62961
--- /dev/null
+++ b/scripts/test-privy-pay.js
@@ -0,0 +1,96 @@
+#!/usr/bin/env node
+/**
+ * test-privy-pay.js — a Google player can pay, and nobody else pays for it.
+ *
+ * OWNER: *"harus bisa pembayaran juga lewat privy"* … *"lanjut pakai CIP-64
+ * aja, jangan smart wallet."*
+ *
+ * Chessify (jadonamite/playchessify) wraps its whole app in PrivyProvider and
+ * pairs it with ERC-4337 + a Pimlico bundler so social players never need gas.
+ * Neither half of that copies over:
+ *
+ * · Wrapping the app would ship the Privy SDK to every MiniPay player, for a
+ * screen they are never shown. That is the cost lib/WagmiIsland.tsx and
+ * lib/walletBridge.tsx exist to avoid, and this follows the same shape —
+ * an island beside the app, publishing into an always-mounted context.
+ * · On Celo, gas can be paid in the stablecoin being spent (CIP-64), which
+ * this app already does. So a player holding USDT and no CELO can buy
+ * without a bundler, a sponsor, or anything to keep funded.
+ *
+ * Source-level, deliberately: signing needs a real Privy wallet and the sandbox
+ * has none. What is checked is that the guarantees exist and cannot quietly
+ * come undone.
+ *
+ * node scripts/test-privy-pay.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 bridge = read('lib/privySignerBridge.tsx')
+const island = read('components/game/PrivySignerIsland.tsx')
+const pub = read('components/game/PrivySignerPublisher.tsx')
+const web3 = read('lib/Web3Providers.tsx')
+const provider = read('lib/WalletProvider.tsx')
+
+// ── MiniPay pays nothing for this ───────────────────────────────────────────
+ok('the always-mounted bridge imports no Privy and no viem runtime',
+ !/from '@privy-io/.test(bridge) && !/^import \{[^}]*\} from 'viem'/m.test(bridge))
+ok('the island is lazily imported, never in the initial chunk',
+ /dynamic\(\(\) => import\('@\/components\/game\/PrivySignerIsland'\), \{ ssr: false \}\)/.test(web3))
+ok('and mounts only when the gate flag says yes — which is false inside MiniPay',
+ /if \(readPrivyGateFlag\(\)\) setMount\(true\)/.test(web3))
+ok('beside the app, not around it, so nothing remounts when it arrives',
+ //.test(web3) && /\{children\}<\/WalletProvider>/.test(web3))
+ok('no app id means no provider, matching the gate',
+ /if \(!appId\) return null/.test(island))
+
+// ── it outlives the login screen, which is the whole reason it exists ───────
+ok('the island renders no UI at all — only the publisher',
+ //.test(island) && !/ns-signin/.test(island))
+
+// ── Celo, in the right order ────────────────────────────────────────────────
+// Privy's docs are explicit that switchChain does not update providers already
+// handed out. Taking the provider first returns a client that signs for the old
+// chain — the same failure the owner hit in the OKX browser.
+ok('the wallet is switched to Celo BEFORE its provider is taken',
+ pub.indexOf('await wallet.switchChain(celo.id)') > 0 &&
+ pub.indexOf('await wallet.switchChain(celo.id)') < pub.indexOf('await wallet.getEthereumProvider()'))
+ok('and the client is pinned to Celo', /chain: celo,/.test(pub))
+ok('the island pins both chain settings too',
+ /defaultChain: celo,/.test(island) && /supportedChains: \[celo\],/.test(island))
+
+// ── CIP-64, not a smart wallet ──────────────────────────────────────────────
+ok('gas is paid in the stablecoin being spent',
+ /feeCurrency: getFeeCurrency\(sym\)/.test(pub))
+ok('via the fee ADAPTER helper, not the raw token address',
+ /getFeeCurrency/.test(pub) && !/feeCurrency: cfg\.address/.test(pub))
+// Checked against IMPORTS, not prose — the comments in these files discuss
+// smart wallets at length precisely because the decision was to not use them.
+ok('and nothing here imports account abstraction',
+ !/from '(permissionless|@privy-io\/react-auth\/smart-wallets)'/.test(pub + island + bridge) &&
+ !/SmartWalletsProvider/.test(pub + island + bridge))
+
+// ── one treasury, one token table ───────────────────────────────────────────
+// The payment is duplicated; the FACTS it is built from must not be.
+ok('the transfer uses the shared treasury and token constants',
+ /TREASURY_WALLET/.test(pub) && /MARKETPLACE_TOKENS/.test(pub) &&
+ /parseTokenAmount/.test(pub) && /from '@\/lib\/constants\/tokens'/.test(pub))
+ok('and carries the same Celo attribution tag', /withAttribution\(data\)/.test(pub))
+
+// ── precedence, and the line that must NOT move ─────────────────────────────
+ok('a real wallet always outranks the embedded one',
+ /const pay = w\.walletClient \? w\.payToTreasury : \(privy\.payToTreasury \?\? w\.payToTreasury\)/.test(provider) &&
+ /walletClient: w\.walletClient \?\? privy\.walletClient/.test(provider))
+// Re-keying the save onto the embedded wallet would orphan every document
+// already stored under the derived account address.
+ok('the SAVE KEY is untouched — the embedded wallet pays, it does not identify',
+ /address: realAddress \?\? authAddress \?\? guestAddress \?\? null,/.test(provider))
+ok('and both spend paths go through the same selector',
+ (provider.match(/: pay,/g) || []).length === 2)
+
+console.log(fails ? ` ${fails} GAGAL` : ' semua lolos')
+console.log(' NOTE: no real Privy wallet signs here — the sandbox has none.')
+process.exit(fails ? 1 : 0)