diff --git a/.env.production b/.env.production index 15155e7..5aef306 100644 --- a/.env.production +++ b/.env.production @@ -1,10 +1,10 @@ # no trailing slash -NEXT_PUBLIC_WORKER_DOMAIN=chess-worker.johnsgresham.workers.dev -NEXT_PUBLIC_URL=https://basedchess.xyz -NEXT_PUBLIC_NEYNAR_API_KEY_FRNT=47FB9C28-6FFC-4086-84C3-ED9B88DBAF27 +# NEXT_PUBLIC_WORKER_DOMAIN=chess-worker.johnsgresham.workers.dev +# NEXT_PUBLIC_URL=https://basedchess.xyz +# NEXT_PUBLIC_NEYNAR_API_KEY_FRNT=47FB9C28-6FFC-4086-84C3-ED9B88DBAF27 # staging because passing env vars is broken in opennextjs-cloudflare # NEXT_PUBLIC_URL=https://based-chess-worker-nextjs-staging.johnsgresham.workers.dev -# NEXT_PUBLIC_WORKER_DOMAIN=chess-worker-staging.johnsgresham.workers.dev -# NEXT_PUBLIC_URL=https://staging.basedchess.xyz -# NEXT_PUBLIC_NEYNAR_API_KEY_FRNT=47FB9C28-6FFC-4086-84C3-ED9B88DBAF27 +NEXT_PUBLIC_WORKER_DOMAIN=chess-worker-staging.johnsgresham.workers.dev +NEXT_PUBLIC_URL=https://sub-accounts-demo.basedchess.xyz +NEXT_PUBLIC_NEYNAR_API_KEY_FRNT=47FB9C28-6FFC-4086-84C3-ED9B88DBAF27 diff --git a/package.json b/package.json index 40b3e92..5bbdffd 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" }, "dependencies": { + "@coinbase/wallet-sdk": "4.4.0-canary.20250402", "@farcaster/frame-sdk": "^0.0.32", "@farcaster/frame-wagmi-connector": "^0.0.20", "@radix-ui/react-accordion": "^1.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6c9799..310502b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@coinbase/wallet-sdk': + specifier: 4.4.0-canary.20250402 + version: 4.4.0-canary.20250402(@types/react@19.0.12)(bufferutil@4.0.9)(react@19.1.0)(typescript@5.8.2)(use-sync-external-store@1.4.0(react@19.1.0))(utf-8-validate@5.0.10)(zod@3.24.2) '@farcaster/frame-sdk': specifier: ^0.0.32 version: 0.0.32(typescript@5.8.2)(zod@3.24.2) @@ -755,6 +758,9 @@ packages: '@coinbase/wallet-sdk@4.3.0': resolution: {integrity: sha512-T3+SNmiCw4HzDm4we9wCHCxlP0pqCiwKe4sOwPH3YAK2KSKjxPRydKu6UQJrdONFVLG7ujXvbd/6ZqmvJb8rkw==} + '@coinbase/wallet-sdk@4.4.0-canary.20250402': + resolution: {integrity: sha512-moQz9aI1+poE49lXFqtTqdg7HeJLEydGEYInSeDJPPV18La2RUUUSnwFEqqufPPE8edIVtbRt4FTy0qyawuMMg==} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -4502,6 +4508,9 @@ packages: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} + preact@10.24.2: + resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==} + preact@10.26.4: resolution: {integrity: sha512-KJhO7LBFTjP71d83trW+Ilnjbo+ySsaAgCfXOXUlmGzJ4ygYPWmysm77yg4emwfmoz3b22yvH5IsVFHbhUaH5w==} @@ -5222,6 +5231,14 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + viem@2.22.17: + resolution: {integrity: sha512-eqNhlPGgRLR29XEVUT2uuaoEyMiaQZEKx63xT1py9OYsE+ZwlVgjnfrqbXad7Flg2iJ0Bs5Hh7o0FfRWUJGHvg==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + viem@2.23.2: resolution: {integrity: sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA==} peerDependencies: @@ -5433,6 +5450,24 @@ packages: use-sync-external-store: optional: true + zustand@5.0.3: + resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adraffy/ens-normalize@1.11.0': {} @@ -7095,6 +7130,26 @@ snapshots: eventemitter3: 5.0.1 preact: 10.26.4 + '@coinbase/wallet-sdk@4.4.0-canary.20250402(@types/react@19.0.12)(bufferutil@4.0.9)(react@19.1.0)(typescript@5.8.2)(use-sync-external-store@1.4.0(react@19.1.0))(utf-8-validate@5.0.10)(zod@3.24.2)': + dependencies: + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@5.8.2)(zod@3.24.2) + preact: 10.24.2 + viem: 2.22.17(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2) + zustand: 5.0.3(@types/react@19.0.12)(react@19.1.0)(use-sync-external-store@1.4.0(react@19.1.0)) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -11702,6 +11757,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.24.2: {} + preact@10.26.4: {} prelude-ls@1.2.1: {} @@ -12460,6 +12517,23 @@ snapshots: vary@1.1.2: {} + viem@2.22.17(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2): + dependencies: + '@noble/curves': 1.8.1 + '@noble/hashes': 1.7.1 + '@scure/bip32': 1.6.2 + '@scure/bip39': 1.5.4 + abitype: 1.0.8(typescript@5.8.2)(zod@3.24.2) + isows: 1.0.6(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + ox: 0.6.7(typescript@5.8.2)(zod@3.24.2) + ws: 8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.8.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + viem@2.23.2(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2): dependencies: '@noble/curves': 1.8.1 @@ -12715,3 +12789,9 @@ snapshots: '@types/react': 19.0.12 react: 19.1.0 use-sync-external-store: 1.4.0(react@19.1.0) + + zustand@5.0.3(@types/react@19.0.12)(react@19.1.0)(use-sync-external-store@1.4.0(react@19.1.0)): + optionalDependencies: + '@types/react': 19.0.12 + react: 19.1.0 + use-sync-external-store: 1.4.0(react@19.1.0) diff --git a/src/app/footer.tsx b/src/app/footer.tsx index 7d65b00..86a669d 100644 --- a/src/app/footer.tsx +++ b/src/app/footer.tsx @@ -3,13 +3,15 @@ import { ArrowUpRight } from "lucide-react"; import { contracts, type SupportedChainId } from "../lib/contracts"; import { blockExplorers } from "../lib/contracts"; import { buttonVariants } from "../components/ui/button"; -import { useAccount } from "wagmi"; +// import { useAccount } from "wagmi"; import Link from "next/link"; import { DarkModeToggle } from "../components/DarkModeToggle"; import { DevModeToggle } from "../components/DevModeToggle"; +// import { useCoinbaseWallet } from "../context/CoinbaseWalletContext"; export const Footer = () => { - const { chainId } = useAccount(); + // const { chainId } = useAccount(); + const chainId: SupportedChainId = 84532; return (
diff --git a/src/app/games/[gameId]/game.tsx b/src/app/games/[gameId]/game.tsx index dd78b90..3e17e7e 100644 --- a/src/app/games/[gameId]/game.tsx +++ b/src/app/games/[gameId]/game.tsx @@ -40,6 +40,8 @@ import { MintGameWinNFTBtn, type MintStep } from "./MintGameWinNFTBtn"; import { useDevMode } from "../../../components/hooks/useLocalSettings"; import { useFarcasterUser } from "../../../components/hooks/useFarcasterUser"; import { FarcasterUser } from "../../../lib/neynar.server"; +import { useCoinbaseWallet } from "../../../context/CoinbaseWalletContext"; +import { recoverMessageAddress } from "viem"; export type WsMessage = { type: string; @@ -84,7 +86,7 @@ export default function Game() { const wsRef = useRef(null); const chessboardRef = useRef(null); const [game, setGame] = useState(); - const { address, isConnected } = useAccount(); + // const { address, isConnected } = useAccount(); const [awaitSigningMove, setAwaitSigningMove] = useState(false); const [boardOrientation, setBoardOrientation] = useState("white"); const params = useParams(); @@ -136,8 +138,8 @@ export default function Game() { >({}); const { showToast, Toast } = useToast(); - const chainId = useChainId(); - + // const chainId = useChainId(); + const chainId: SupportedChainId = 84532; const [winner, setWinner] = useState<`0x${string}` | undefined>(); const [isNFTMinted, setIsNFTMinted] = useState(false); const [isNFTReadyToMint, setIsNFTReadyToMint] = useState(false); @@ -177,6 +179,47 @@ export default function Game() { args: [contracts.gamesContract[chainId as SupportedChainId].address, contractGameId], }); + // [start] Coinbase Sub Account Wallet specifics + const { + isConnected, + connect, + disconnect, + address, + subAccount, + createSubAccount, + subAccountWalletClient, + provider, + } = useCoinbaseWallet(); + const [signature, setSignature] = useState(null); + + const signMessageSubAccount = useCallback( + async (message: string) => { + if (!subAccountWalletClient || !subAccount) { + // open create sub account for user + createSubAccount(); + console.error("Subaccount wallet client or subaccount not found"); + throw new Error("Subaccount wallet client or subaccount not found"); + } + + const signature = await subAccountWalletClient.signMessage({ + message, + account: subAccount, + }); + console.log("signature", signature); + // Error: Invalid yParityOrV value + // const addr = await recoverMessageAddress({ + // message, + // signature, + // }); + // console.log("recoverMessageAddress addr", addr); + + setSignature(signature); + return signature; + }, + [subAccountWalletClient, subAccount, createSubAccount], + ); + // [end] Coinbase Sub Account Wallet specifics + useEffect(() => { setAudioPlayerDropChessPiece(new Audio("/sounds/drop_piece.mp3")); setAudioPlayerLoseGame(new Audio("/sounds/lose_game.mp3")); @@ -678,17 +721,21 @@ export default function Game() { // sign move const message = game.pgn(); // const message = JSON.stringify(moveMove.lan) - const signature = await signMessage(frameWagmiConfig, { - message, - }); + // const signature = await signMessage(frameWagmiConfig, { + // message, + // }); + const signature = await signMessageSubAccount(message); + console.log("user move signature:", signature); - const verified = await verifyMessage(frameWagmiConfig, { - address, - message, - signature, - }); - console.log("user move signature verified:", verified); + // todo: verify the sub account signature + + // const verified = await verifyMessage(frameWagmiConfig, { + // address, + // message, + // signature, + // }); + // console.log("user move signature verified:", verified); setAwaitSigningMove(false); // update server @@ -718,6 +765,7 @@ export default function Game() { signature, message, address, + subAccount, }, }), ); @@ -744,6 +792,7 @@ export default function Game() { signature, message, address, + subAccount, }, }), ); @@ -770,6 +819,7 @@ export default function Game() { signature, message, address, + subAccount, }, }), ); diff --git a/src/app/header.tsx b/src/app/header.tsx index df6655e..1d0bd53 100644 --- a/src/app/header.tsx +++ b/src/app/header.tsx @@ -1,9 +1,15 @@ "use client"; + +import { Button } from "../components/ui/button"; import Link from "next/link"; import Image from "next/image"; -import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { useCoinbaseWallet } from "../context/CoinbaseWalletContext"; +import DisplayAddress from "../components/util/DisplayAddress"; export const Header = () => { + const { isConnected, connect, disconnect, address, subAccount, createSubAccount } = + useCoinbaseWallet(); + return (
@@ -22,10 +28,37 @@ export const Header = () => {
DEV
)} {process.env.NEXT_PUBLIC_WORKER_DOMAIN?.includes("staging") && ( -
STAGING
+
Sub Accounts Demo
)}
- + {/* */} + {isConnected ? ( +
+ + Account: + + + {!subAccount && ( + + )} + + {subAccount && ( + + Sub Account: + + )} + + +
+ ) : ( + + )}
); diff --git a/src/app/page.tsx b/src/app/page.tsx index 1f5c2c4..bd3e440 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,6 +10,7 @@ import GameSummary from "../components/GameSummary"; import { Separator } from "@/components/ui/separator"; import { useSetFarcasterContext } from "../components/hooks/useFarcasterContext"; import type { FarcasterUser } from "../lib/neynar.server"; +import { useCoinbaseWallet } from "../context/CoinbaseWalletContext"; // export function metadata() { // return [ @@ -61,11 +62,21 @@ export type GameData = { export default function Page() { const [games, setGames] = useState([]); - const { address } = useAccount(); + // const { address } = useAccount(); const [isSDKLoaded, setIsSDKLoaded] = useState(false); const [context, setContext] = useState(); const [errorFetchGames, setErrorFetchGames] = useState(""); const { mutate: setFarcasterContext } = useSetFarcasterContext(); + const { + isConnected, + connect, + disconnect, + address, + subAccount, + createSubAccount, + subAccountWalletClient, + provider, + } = useCoinbaseWallet(); useEffect(() => { const load = async () => { diff --git a/src/components/NewGameSheet.tsx b/src/components/NewGameSheet.tsx index f63a886..dd77fdc 100644 --- a/src/components/NewGameSheet.tsx +++ b/src/components/NewGameSheet.tsx @@ -25,6 +25,7 @@ import { useConnectModal } from "@rainbow-me/rainbowkit"; import SearchSelectUser, { type User as FarcasterUserSelect } from "./SearchSelectUser"; import { Tabs, TabsTrigger, TabsList, TabsContent } from "@/components/ui/tabs"; import { useFarcasterContext } from "./hooks/useFarcasterContext"; +import { useCoinbaseWallet } from "../context/CoinbaseWalletContext"; export default function NewGameSheet() { const [open, setOpen] = useState(false); @@ -38,12 +39,22 @@ export default function NewGameSheet() { const [openAfterConnect, setOpenAfterConnect] = useState(false); const [errorCreateGame, setErrorCreateGame] = useState(""); const router = useRouter(); - const { address } = useAccount(); + // const { address } = useAccount(); const { openConnectModal } = useConnectModal(); const { data: farcasterContext } = useFarcasterContext(); const [selectedFarcasterUser, setSelectedFarcasterUser] = useState( null, ); + const { + isConnected, + connect, + disconnect, + address, + subAccount, + createSubAccount, + subAccountWalletClient, + provider, + } = useCoinbaseWallet(); useEffect(() => { if (openAfterConnect && address) { diff --git a/src/components/providers/providers.tsx b/src/components/providers/providers.tsx index 029ffc7..734a263 100644 --- a/src/components/providers/providers.tsx +++ b/src/components/providers/providers.tsx @@ -6,6 +6,7 @@ import { useState, useEffect } from "react"; import { darkTheme } from "@rainbow-me/rainbowkit"; import { ThemeProvider } from "@/components/providers/theme-provider"; import { useTheme } from "next-themes"; +import { CoinbaseWalletProvider } from "@/context/CoinbaseWalletContext"; export default function Providers({ children }: { children: React.ReactNode }) { const [mounted, setMounted] = useState(false); @@ -43,15 +44,17 @@ export default function Providers({ children }: { children: React.ReactNode }) { return ( - {/* To provide theme for RainbowKit in Providers */} - - {children} - + + {/* To provide theme for RainbowKit in Providers */} + + {children} + + ); diff --git a/src/components/util/DisplayAddress.tsx b/src/components/util/DisplayAddress.tsx index 9836736..37ba768 100644 --- a/src/components/util/DisplayAddress.tsx +++ b/src/components/util/DisplayAddress.tsx @@ -4,8 +4,7 @@ import { mainnetConfig } from "../../lib/wagmiconfig"; import { normalize } from "viem/ens"; import { EmojiAvatar } from "./EmojiAvatar"; import type { FarcasterUser } from "../../lib/neynar.server"; -import { Check } from "lucide-react"; -import { Copy } from "lucide-react"; +import { Check, Copy } from "lucide-react"; import { useState } from "react"; // Displays an ethereum address in a truncated format by showing the first 6 and last 4 characters @@ -43,7 +42,7 @@ export default function DisplayAddress({ }; return ( -
+
Promise; + disconnect: () => void; + isConnected: boolean; + address: Address | null; + subAccount: Address | null; + createSubAccount: () => Promise
; + subAccountWalletClient: WalletClient | null; +} + +const CoinbaseWalletContext = createContext({ + provider: null, + connect: async () => {}, + disconnect: () => {}, + isConnected: false, + address: null, + subAccount: null, + createSubAccount: async () => null, + subAccountWalletClient: null, +}); + +export function CoinbaseWalletProvider({ children }: { children: ReactNode }) { + const [provider, setProvider] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [address, setAddress] = useState
(null); + const [subAccount, setSubAccount] = useState
(null); + const [subAccountWalletClient, setSubAccountWalletClient] = useState(null); + + useEffect(() => { + // Initialize Coinbase Wallet SDK + const coinbaseWalletSDK = createCoinbaseWalletSDK({ + appName: "Based Chess x Coinbase Sub Account demo", + appChainIds: [baseSepolia.id], + preference: { + options: "smartWalletOnly", + keysUrl: "https://keys-dev.coinbase.com/connect", + }, + toSubAccountSigner: getCryptoKeyAccount, + }); + setProvider(coinbaseWalletSDK.getProvider()); + }, []); + + // Add public client for account creation + const publicClient = useMemo( + () => + createPublicClient({ + chain: baseSepolia, + transport: http(), + }), + [], + ); + + // Add a new useEffect to initialize a Sub Account wallet client when Sub Account is created + useEffect(() => { + async function initializeSubAccountClient() { + if (!subAccount || !provider || !publicClient) { + setSubAccountWalletClient(null); + return; + } + + try { + const signer = await getCryptoKeyAccount(); + if (!signer) { + throw new Error("Signer not found"); + } + + const account = await toCoinbaseSmartAccount({ + client: publicClient, + owners: [signer.account as WebAuthnAccount], + address: subAccount, + }); + + const client = createWalletClient({ + account, + chain: baseSepolia, + transport: custom({ + async request({ method, params }) { + const response = await provider.request({ method, params }); + return response; + }, + }), + }); + + setSubAccountWalletClient(client); + } catch (error) { + console.error("Failed to initialize subAccount wallet client:", error); + setSubAccountWalletClient(null); + } + } + + initializeSubAccountClient(); + }, [subAccount, provider, publicClient]); + + const walletClient = useMemo(() => { + if (!provider) return null; + return createWalletClient({ + chain: baseSepolia, + transport: custom({ + async request({ method, params }) { + const response = await provider.request({ method, params }); + return response; + }, + }), + }); + }, [provider]); + + const connect = useCallback(async () => { + if (!walletClient || !provider) return; + walletClient.requestAddresses().then(async (addresses) => { + if (addresses.length > 0) { + setAddress(addresses[0]); + setIsConnected(true); + } + }); + }, [walletClient, provider]); + + const disconnect = () => { + if (!provider) return; + try { + provider.disconnect(); + setIsConnected(false); + setAddress(null); + } catch (error) { + console.error("Failed to disconnect from Coinbase Wallet:", error); + } + }; + + const createSubAccount = useCallback(async () => { + if (!provider || !address) { + throw new Error("Address or provider not found"); + } + + const signer = await getCryptoKeyAccount(); + + const walletConnectResponse = (await provider.request({ + method: "wallet_connect", + params: [ + { + version: "1", + capabilities: { + addSubAccount: { + account: { + type: "create", + keys: [ + { + type: "webauthn-p256", + key: signer.account?.publicKey, + }, + ], + }, + }, + }, + }, + ], + })) as { + accounts: { + address: Address; + capabilities: { + addSubAccount: { + address: Address; + }; + }; + }[]; + }; + const { addSubAccount } = walletConnectResponse.accounts[0].capabilities; + const subAccount = addSubAccount.address; + setSubAccount(subAccount); + return subAccount; + }, [provider, address]); + + return ( + + {children} + + ); +} + +export function useCoinbaseWallet() { + const context = useContext(CoinbaseWalletContext); + if (context === undefined) { + throw new Error("useCoinbaseWallet must be used within a CoinbaseWalletProvider"); + } + return context; +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 32e4f2b..ae70297 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -13,6 +13,40 @@ "head_sampling_rate": 1 }, "env": { + "sub-accounts-demo": { + "name": "based-chess-worker-nextjs-sub-accounts-demo", + "vars": { + "ENVIRONMENT": "staging", + "CHAIN_ID": "84532" + // "NEXT_PUBLIC_URL": "https://staging.basedchess.xyz", + // "NEXT_PUBLIC_WORKER_DOMAIN": "chess-worker-staging.johnsgresham.workers.dev" + }, + // "build": { + // "command": "NODE_ENV=staging npm run build" + // }, + "durable_objects": { + "bindings": [ + { + "name": "CHESS_GAME", + "class_name": "ChessGame", + "script_name": "chess-worker-staging" + } + ] + }, + "services": [ + { + "binding": "RPC_SERVICE", + "service": "chess-worker", + "entrypoint": "SessionsRPC" + } + ], + "routes": [ + { + "pattern": "sub-accounts-demo.basedchess.xyz/*", + "zone_name": "basedchess.xyz" + } + ] + }, "staging": { "name": "based-chess-worker-nextjs-staging", "vars": {