From 5a88f288ba9f14dd48d283c483a18d23739efc62 Mon Sep 17 00:00:00 2001 From: 0xward <0xward.dev@gmail.com> Date: Mon, 3 Aug 2026 07:08:31 +0000 Subject: [PATCH] A Google player can pay, and no MiniPay player pays for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OWNER: *"harus bisa pembayaran juga lewat privy"* … *"lanjut pakai CIP-64 aja, jangan smart wallet."* The owner pointed at Chessify (jadonamite/playchessify) as the model. Reading it was worth it — it is Privy + wagmi on Celo, and its Privy config is almost character-for-character the one merged an hour ago. But neither half of its architecture copies over, and both refusals are load-bearing: · It wraps the WHOLE APP in PrivyProvider. That would ship the Privy SDK to every MiniPay player, for a screen they are never shown, on the connections least able to afford the download — the exact cost lib/WagmiIsland.tsx and lib/walletBridge.tsx were built to avoid. So this follows the same shape instead: an island beside the app, publishing into an always-mounted context that imports neither Privy nor viem. /game grew 1 kB. · It pairs Privy with ERC-4337 and a Pimlico bundler so social players never need gas. On Celo that solves a problem the chain already solved: CIP-64 pays gas in the stablecoin being spent, and this app has done that since the MiniPay work (pickBestFeeCurrency). A player holding USDT and no CELO can buy with no bundler, no sponsor, and nothing to keep funded. THE SHAPE. lib/privySignerBridge.tsx is context only — no SDK, always mounted, costs nothing. components/game/PrivySignerIsland.tsx is lazy, mounts only when readPrivyGateFlag() says yes (false inside MiniPay, false with no app id, false by default), renders no UI, and lives for the whole session — which the login screen cannot, since it unmounts the moment the player is signed in. ORDER MATTERS IN ONE PLACE. Privy's docs are explicit that switchChain does not update providers already handed out. So the wallet is moved to Celo BEFORE its EIP-1193 provider is taken; the other order returns a client that still signs for the old chain, which is precisely the failure the owner hit in OKX. PRECEDENCE. wagmi's wallet wins whenever there is one — a player who connected MiniPay or a browser wallet has said which wallet they mean. Privy's is used only when there is no other. Both spend paths resolve through one selector, so they cannot disagree. AND ONE LINE THAT DOES NOT MOVE. `address` still resolves realAddress → authAddress → guestAddress. The save is keyed on the address derived in lib/authIdentity.ts, and re-pointing it at 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. Verified: 17/17 test:privypay, 24/24 test:privy, 20/20 test:network, tsc clean, build 143/242 kB (+1 kB, the context), check:copy clean, lint 35. NOT covered, and stated rather than implied: no real Privy wallet signs anything here, because the sandbox has none. The assertions are source-level. Still unanswered, and the owner's to answer: where the USDT comes from. Gas is solved; the price is not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017A764RdnwpyWnG7uCNhMiQ --- components/game/PrivySignerIsland.tsx | 48 ++++++++++ components/game/PrivySignerPublisher.tsx | 113 +++++++++++++++++++++++ lib/WalletProvider.tsx | 25 ++++- lib/Web3Providers.tsx | 35 ++++++- lib/privySignerBridge.tsx | 100 ++++++++++++++++++++ package.json | 3 +- scripts/test-privy-pay.js | 96 +++++++++++++++++++ 7 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 components/game/PrivySignerIsland.tsx create mode 100644 components/game/PrivySignerPublisher.tsx create mode 100644 lib/privySignerBridge.tsx create mode 100644 scripts/test-privy-pay.js 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)