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)