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
48 changes: 48 additions & 0 deletions components/game/PrivySignerIsland.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PrivyProvider
appId={appId}
config={{
loginMethods: ['google', 'email', 'wallet'],
appearance: { theme: 'dark', accentColor: '#39ff9a' },
embeddedWallets: { ethereum: { createOnLogin: 'users-without-wallets' } },
defaultChain: celo,
supportedChains: [celo],
}}
>
<PrivySignerPublisher />
</PrivyProvider>
)
}
113 changes: 113 additions & 0 deletions components/game/PrivySignerPublisher.tsx
Original file line number Diff line number Diff line change
@@ -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<string> => {
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
}
25 changes: 22 additions & 3 deletions lib/WalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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
Expand All @@ -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,
}
}

Expand Down
35 changes: 33 additions & 2 deletions lib/Web3Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -60,11 +78,24 @@ function DeferredWagmi() {
return mount ? <WagmiIsland /> : 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 ? <PrivySignerIsland /> : null
}

export default function Web3Providers({ children }: { children: ReactNode }) {
return (
<WalletBridgeProvider>
<WalletProvider>{children}</WalletProvider>
<DeferredWagmi />
<PrivySignerBridgeProvider>
<WalletProvider>{children}</WalletProvider>
<DeferredWagmi />
<DeferredPrivySigner />
</PrivySignerBridgeProvider>
</WalletBridgeProvider>
)
}
100 changes: 100 additions & 0 deletions lib/privySignerBridge.tsx
Original file line number Diff line number Diff line change
@@ -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<Transport, CeloChain, Account> | 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<string>) | null
}

export const PRIVY_SIGNER_DEFAULT: PrivySignerValue = {
ready: false,
address: null,
walletClient: null,
payToTreasury: null,
}

const ValueContext = createContext<PrivySignerValue>(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<PrivySignerValue>(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 (
<PublishContext.Provider value={publish}>
<ValueContext.Provider value={value}>{children}</ValueContext.Provider>
</PublishContext.Provider>
)
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading