diff --git a/README.md b/README.md index fa2c92c..fb244a3 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ This is a [NextJS](https://nextjs.org/) + TypeScript + React app. ## Guide -Check out [this Neynar docs page](https://docs.neynar.com/docs/create-v2-farcaster-frame-in-60s) for a simple guide on how to create a Farcaster Mini App in less than 60 seconds! +Check out [this Neynar docs page](https://docs.neynar.com/docs/create-farcaster-miniapp-in-60s) for a simple guide on how to create a Farcaster Mini App in less than 60 seconds! ## Getting Started -To create a new frames project, run: +To create a new mini app project, run: ```{bash} npx @neynar/create-farcaster-mini-app@latest ``` @@ -41,3 +41,39 @@ npm run build ``` The above command will generate a `.env` file based on the `.env.local` file and user input. Be sure to configure those environment variables on your hosting platform. + +## Developing Script Locally + +This section is only for working on the script and template. If you simply want to create a mini app and _use_ the template, this section is not for you. + +### Recommended: Using `npm link` for Local Development + +To iterate on the CLI and test changes in a generated app without publishing to npm: + +1. In your installer/template repo (this repo), run: + ```bash + npm link + ``` + This makes your local version globally available as a symlinked package. + + +1. Now, when you run: + ```bash + npx @neynar/create-farcaster-mini-app + ``` + ...it will use your local changes (including any edits to `init.js` or other files) instead of the published npm version. + +### Alternative: Running the Script Directly + +You can also run the script directly for quick iteration: + +```bash +node ./bin/index.js +``` + +However, this does not fully replicate the npx install flow and may not catch all issues that would occur in a real user environment. + +### Environment Variables and Scripts + +If you update environment variable handling, remember to replicate any changes in the `dev`, `build`, and `deploy` scripts as needed. The `build` and `deploy` scripts may need further updates and are less critical for most development workflows. + diff --git a/bin/init.js b/bin/init.js index 90575ff..388d81b 100644 --- a/bin/init.js +++ b/bin/init.js @@ -328,7 +328,9 @@ export async function init() { "@farcaster/frame-node": ">=0.0.18 <1.0.0", "@farcaster/frame-sdk": ">=0.0.31 <1.0.0", "@farcaster/frame-wagmi-connector": ">=0.0.19 <1.0.0", + "@farcaster/mini-app-solana": "^0.0.5", "@radix-ui/react-label": "^2.1.1", + "@solana/wallet-adapter-react": "^0.15.38", "@tanstack/react-query": "^5.61.0", "@upstash/redis": "^1.34.3", "class-variance-authority": "^0.7.1", diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 794a016..e506826 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -4,6 +4,7 @@ import dynamic from "next/dynamic"; import type { Session } from "next-auth" import { SessionProvider } from "next-auth/react" import { FrameProvider } from "~/components/providers/FrameProvider"; +import { SafeFarcasterSolanaProvider } from "~/components/providers/SafeFarcasterSolanaProvider"; const WagmiProvider = dynamic( () => import("~/components/providers/WagmiProvider"), @@ -13,11 +14,14 @@ const WagmiProvider = dynamic( ); export function Providers({ session, children }: { session: Session | null, children: React.ReactNode }) { + const solanaEndpoint = process.env.SOLANA_RPC_ENDPOINT || "https://solana-rpc.publicnode.com"; return ( - {children} + + {children} + diff --git a/src/components/Demo.tsx b/src/components/Demo.tsx index 1f81c58..980b046 100644 --- a/src/components/Demo.tsx +++ b/src/components/Demo.tsx @@ -17,6 +17,11 @@ import { useSwitchChain, useChainId, } from "wagmi"; +import { + useConnection as useSolanaConnection, + useWallet as useSolanaWallet, +} from '@solana/wallet-adapter-react'; +import { useHasSolanaProvider } from "./providers/SafeFarcasterSolanaProvider"; import { config } from "~/components/providers/WagmiProvider"; import { Button } from "~/components/ui/Button"; @@ -26,6 +31,7 @@ import { BaseError, UserRejectedRequestError } from "viem"; import { useSession } from "next-auth/react"; import { Label } from "~/components/ui/label"; import { useFrame } from "~/components/providers/FrameProvider"; +import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; export default function Demo( { title }: { title?: string } = { title: "Frames v2 Demo" } @@ -38,6 +44,13 @@ export default function Demo( const { address, isConnected } = useAccount(); const chainId = useChainId(); + const hasSolanaProvider = useHasSolanaProvider(); + let solanaWallet, solanaPublicKey, solanaSignMessage, solanaAddress; + if (hasSolanaProvider) { + solanaWallet = useSolanaWallet(); + ({ publicKey: solanaPublicKey, signMessage: solanaSignMessage } = solanaWallet); + solanaAddress = solanaPublicKey?.toBase58(); + } useEffect(() => { console.log("isSDKLoaded", isSDKLoaded); @@ -359,7 +372,7 @@ export default function Demo(
- +
{isConnected && ( @@ -413,12 +426,150 @@ export default function Demo( )} + + {solanaAddress && ( +
+

Solana

+
+ Address:
{truncateAddress(solanaAddress)}
+
+ +
+ +
+
+ )} ); } -function SignMessage() { +// Solana functions inspired by farcaster demo +// https://github.com/farcasterxyz/frames-v2-demo/blob/main/src/components/Demo.tsx +function SignSolanaMessage({ signMessage }: { signMessage?: (message: Uint8Array) => Promise }) { + const [signature, setSignature] = useState(); + const [signError, setSignError] = useState(); + const [signPending, setSignPending] = useState(false); + + const handleSignMessage = useCallback(async () => { + setSignPending(true); + try { + if (!signMessage) { + throw new Error('no Solana signMessage'); + } + const input = new TextEncoder().encode("Hello from Solana!"); + const signatureBytes = await signMessage(input); + const signature = btoa(String.fromCharCode(...signatureBytes)); + setSignature(signature); + setSignError(undefined); + } catch (e) { + if (e instanceof Error) { + setSignError(e); + } + } finally { + setSignPending(false); + } + }, [signMessage]); + + return ( + <> + + {signError && renderError(signError)} + {signature && ( +
+
Signature: {signature}
+
+ )} + + ); +} + +function SendSolana() { + const [state, setState] = useState< + | { status: 'none' } + | { status: 'pending' } + | { status: 'error'; error: Error } + | { status: 'success'; signature: string } + >({ status: 'none' }); + + const { connection: solanaConnection } = useSolanaConnection(); + const { sendTransaction, publicKey } = useSolanaWallet(); + + // This should be replaced but including it from the original demo + // https://github.com/farcasterxyz/frames-v2-demo/blob/main/src/components/Demo.tsx#L718 + const ashoatsPhantomSolanaWallet = 'Ao3gLNZAsbrmnusWVqQCPMrcqNi6jdYgu8T6NCoXXQu1'; + + const handleSend = useCallback(async () => { + setState({ status: 'pending' }); + try { + if (!publicKey) { + throw new Error('no Solana publicKey'); + } + + const { blockhash } = await solanaConnection.getLatestBlockhash(); + if (!blockhash) { + throw new Error('failed to fetch latest Solana blockhash'); + } + + const fromPubkeyStr = publicKey.toBase58(); + const toPubkeyStr = ashoatsPhantomSolanaWallet; + const transaction = new Transaction(); + transaction.add( + SystemProgram.transfer({ + fromPubkey: new PublicKey(fromPubkeyStr), + toPubkey: new PublicKey(toPubkeyStr), + lamports: 0n, + }), + ); + transaction.recentBlockhash = blockhash; + transaction.feePayer = new PublicKey(fromPubkeyStr); + + const simulation = await solanaConnection.simulateTransaction(transaction); + if (simulation.value.err) { + // Gather logs and error details for debugging + const logs = simulation.value.logs?.join('\n') ?? 'No logs'; + const errDetail = JSON.stringify(simulation.value.err); + throw new Error(`Simulation failed: ${errDetail}\nLogs:\n${logs}`); + } + const signature = await sendTransaction(transaction, solanaConnection); + setState({ status: 'success', signature }); + } catch (e) { + if (e instanceof Error) { + setState({ status: 'error', error: e }); + } else { + setState({ status: 'none' }); + } + } + }, [sendTransaction, publicKey, solanaConnection]); + + return ( + <> + + {state.status === 'error' && renderError(state.error)} + {state.status === 'success' && ( +
+
Hash: {truncateAddress(state.signature)}
+
+ )} + + ); +} + +function SignEvmMessage() { const { isConnected } = useAccount(); const { connectAsync } = useConnect(); const { diff --git a/src/components/providers/SafeFarcasterSolanaProvider.tsx b/src/components/providers/SafeFarcasterSolanaProvider.tsx new file mode 100644 index 0000000..2d6c063 --- /dev/null +++ b/src/components/providers/SafeFarcasterSolanaProvider.tsx @@ -0,0 +1,86 @@ +import React, { createContext, useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import { sdk } from '@farcaster/frame-sdk'; + +const FarcasterSolanaProvider = dynamic( + () => import('@farcaster/mini-app-solana').then(mod => mod.FarcasterSolanaProvider), + { ssr: false } +); + +type SafeFarcasterSolanaProviderProps = { + endpoint: string; + children: React.ReactNode; +}; + +const SolanaProviderContext = createContext<{ hasSolanaProvider: boolean }>({ hasSolanaProvider: false }); + +export function SafeFarcasterSolanaProvider({ endpoint, children }: SafeFarcasterSolanaProviderProps) { + const isClient = typeof window !== "undefined"; + const [hasSolanaProvider, setHasSolanaProvider] = useState(false); + const [checked, setChecked] = useState(false); + + useEffect(() => { + if (!isClient) return; + let cancelled = false; + (async () => { + try { + const provider = await sdk.wallet.getSolanaProvider(); + if (!cancelled) { + setHasSolanaProvider(!!provider); + } + } catch { + if (!cancelled) { + setHasSolanaProvider(false); + } + } finally { + if (!cancelled) { + setChecked(true); + } + } + })(); + return () => { + cancelled = true; + }; + }, [isClient]); + + useEffect(() => { + let errorShown = false; + const origError = console.error; + console.error = (...args) => { + if ( + typeof args[0] === "string" && + args[0].includes("WalletConnectionError: could not get Solana provider") + ) { + if (!errorShown) { + origError(...args); + errorShown = true; + } + return; + } + origError(...args); + }; + return () => { + console.error = origError; + }; + }, []); + + if (!isClient || !checked) { + return null; + } + + return ( + + {hasSolanaProvider ? ( + + {children} + + ) : ( + <>{children} + )} + + ); +} + +export function useHasSolanaProvider() { + return React.useContext(SolanaProviderContext).hasSolanaProvider; +}