diff --git a/.env.example b/.env.example index 4711c0e..9b0f870 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,3 @@ KV_REST_API_TOKEN='' KV_REST_API_URL='' NEXT_PUBLIC_URL='http://localhost:3000' NEXTAUTH_URL='http://localhost:3000' -NEYNAR_API_KEY='FARCASTER_V2_FRAMES_DEMO' diff --git a/bin/index.js b/bin/index.js index f01e57f..3c18913 100755 --- a/bin/index.js +++ b/bin/index.js @@ -6,6 +6,7 @@ import { dirname } from 'path'; import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; +import { mnemonicToAccount } from 'viem/accounts'; import { generateManifest } from './manifest.js'; const __filename = fileURLToPath(import.meta.url); @@ -14,6 +15,33 @@ const __dirname = dirname(__filename); const REPO_URL = 'https://github.com/lucas-neynar/frames-v2-quickstart.git'; const SCRIPT_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; +async function lookupFidByCustodyAddress(custodyAddress, apiKey) { + if (!apiKey) { + throw new Error('Neynar API key is required'); + } + + const response = await fetch( + `https://api.neynar.com/v2/farcaster/user/custody-address?custody_address=${custodyAddress}`, + { + headers: { + 'accept': 'application/json', + 'x-api-key': apiKey + } + } + ); + + if (!response.ok) { + throw new Error(`Failed to lookup FID: ${response.statusText}`); + } + + const data = await response.json(); + if (!data.user?.fid) { + throw new Error('No FID found for this custody address'); + } + + return data.user.fid; +} + async function init() { const answers = await inquirer.prompt([ { @@ -59,16 +87,35 @@ async function init() { { type: 'password', name: 'seedPhrase', - message: 'Enter your Farcaster custody account seed phrase:', - validate: (input) => { - if (input.trim() === '') { - return 'Seed phrase cannot be empty'; - } - return true; - } + message: 'Enter your Farcaster custody account seed phrase to generate a signed manifest for your frame\n(optional -- leave blank to create an unsigned frame)\n(seed phrase is only ever stored locally)\n\nSeed phrase:', + default: null + }, + { + type: 'confirm', + name: 'useNeynar', + message: 'Would you like to use Neynar in your frame?', + default: true } ]); + // If using Neynar, ask for API key + if (answers.useNeynar) { + const neynarAnswers = await inquirer.prompt([ + { + type: 'password', + name: 'neynarApiKey', + message: 'Enter your Neynar API key:', + validate: (input) => { + if (input.trim() === '') { + return 'Neynar API key cannot be empty'; + } + return true; + } + } + ]); + answers.neynarApiKey = neynarAnswers.neynarApiKey; + } + const projectName = answers.projectName; const projectPath = path.join(process.cwd(), projectName); @@ -99,6 +146,13 @@ async function init() { delete packageJson.license; delete packageJson.bin; delete packageJson.files; + + // Add Neynar dependencies if selected + if (answers.useNeynar) { + packageJson.dependencies['@neynar/nodejs-sdk'] = '^2.19.0'; + packageJson.dependencies['@neynar/react'] = '^1.2.0'; + } + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); // Handle .env file @@ -110,25 +164,36 @@ async function init() { const envExampleContent = fs.readFileSync(envExamplePath, 'utf8'); // Write it to .env fs.writeFileSync(envPath, envExampleContent); - // Append project name, description, and button text to .env + + // Generate custody address from seed phrase + if (answers.seedPhrase) { + const account = mnemonicToAccount(answers.seedPhrase); + const custodyAddress = account.address; + + // Look up FID using custody address + console.log('\nLooking up FID...', answers.useNeynar, answers.neynarApiKey); + const neynarApiKey = answers.useNeynar ? answers.neynarApiKey : 'FARCASTER_V2_FRAMES_DEMO'; + console.log('neynarApiKey', neynarApiKey); + const fid = await lookupFidByCustodyAddress(custodyAddress, neynarApiKey); + + // Write seed phrase and FID to .env for manifest signature generation + fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`); + fs.appendFileSync(envPath, `\nFID="${fid}"`); + } + + // Append all environment variables fs.appendFileSync(envPath, `\nNEXT_PUBLIC_FRAME_NAME="${answers.projectName}"`); fs.appendFileSync(envPath, `\nNEXT_PUBLIC_FRAME_DESCRIPTION="${answers.description}"`); fs.appendFileSync(envPath, `\nNEXT_PUBLIC_FRAME_BUTTON_TEXT="${answers.buttonText}"`); fs.appendFileSync(envPath, `\nNEXT_PUBLIC_FRAME_SPLASH_IMAGE_URL="${answers.splashImageUrl}"`); + fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${answers.useNeynar ? answers.neynarApiKey : 'FARCASTER_V2_FRAMES_DEMO'}"`); + fs.unlinkSync(envExamplePath); console.log('\nCreated .env file from .env.example'); } else { console.log('\n.env.example does not exist, skipping copy and remove operations'); } - // Generate manifest and write to public folder - console.log('\nGenerating manifest...'); - const manifest = await generateManifest(answers.seedPhrase, projectPath); - fs.writeFileSync( - path.join(projectPath, 'public/manifest.json'), - JSON.stringify(manifest) - ); - // Update README console.log('\nUpdating README...'); const readmePath = path.join(projectPath, 'README.md'); diff --git a/package.json b/package.json index f399eb9..3705e38 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "next": "15.0.3", "next-auth": "^4.24.11", "ox": "^0.4.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19", + "react-dom": "^19", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "viem": "^2.23.6", @@ -50,8 +50,8 @@ }, "devDependencies": { "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", + "@types/react": "^19", + "@types/react-dom": "^19", "eslint": "^8", "eslint-config-next": "15.0.3", "postcss": "^8", diff --git a/src/app/.well-known/farcaster.json/route.ts b/src/app/.well-known/farcaster.json/route.ts index 94788c9..e42b32d 100644 --- a/src/app/.well-known/farcaster.json/route.ts +++ b/src/app/.well-known/farcaster.json/route.ts @@ -1,34 +1,12 @@ -import { readFileSync } from 'fs'; -import { join } from 'path'; +import { NextResponse } from 'next/server'; +import { generateFarcasterMetadata } from '../../../lib/utils'; export async function GET() { - const appUrl = process.env.NEXT_PUBLIC_URL; - const splashImageUrl = process.env.NEXT_PUBLIC_FRAME_SPLASH_IMAGE_URL || `${appUrl}/splash.png`; - - let accountAssociation; // TODO: add type try { - const manifestPath = join(process.cwd(), 'public/manifest.json'); - const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); - accountAssociation = manifest; + const config = await generateFarcasterMetadata(); + return NextResponse.json(config); } catch (error) { - console.warn('Warning: manifest.json not found or invalid. Frame will not be associated with an account.'); - accountAssociation = null; + console.error('Error generating metadata:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); } - - const config = { - ...(accountAssociation && { accountAssociation }), - frame: { - version: "1", - name: process.env.NEXT_PUBLIC_FRAME_NAME || "Frames v2 Demo", - iconUrl: `${appUrl}/icon.png`, - homeUrl: appUrl, - imageUrl: `${appUrl}/frames/hello/opengraph-image`, - buttonTitle: process.env.NEXT_PUBLIC_FRAME_BUTTON_TEXT || "Launch Frame", - splashImageUrl, - splashBackgroundColor: "#f7f7f7", - webhookUrl: `${appUrl}/api/webhook`, - }, - }; - - return Response.json(config); } diff --git a/src/app/app.tsx b/src/app/app.tsx index 612a20d..c9071e9 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -9,7 +9,7 @@ const Demo = dynamic(() => import("~/components/Demo"), { }); export default function App( - { title }: { title?: string } = { title: "Frames v2 Demo" } + { title }: { title?: string } = { title: process.env.NEXT_PUBLIC_FRAME_NAME || "Frames v2 Demo" } ) { return ; } diff --git a/src/lib/neynar.ts b/src/lib/neynar.ts new file mode 100644 index 0000000..cfc87c5 --- /dev/null +++ b/src/lib/neynar.ts @@ -0,0 +1,18 @@ +import { NeynarAPIClient } from '@neynar/nodejs-sdk'; + +let neynarClient: NeynarAPIClient | null = null; + +export function getNeynarClient() { + if (!neynarClient) { + const apiKey = process.env.NEYNAR_API_KEY; + if (!apiKey) { + throw new Error('NEYNAR_API_KEY not configured'); + } + neynarClient = new NeynarAPIClient(apiKey); + } + return neynarClient; +} + +// Example usage: +// const client = getNeynarClient(); +// const user = await client.lookupUserByFid(fid); \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..8498631 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,79 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" +import { mnemonicToAccount } from 'viem/accounts'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function getSecretEnvVars() { + const seedPhrase = process.env.SEED_PHRASE; + const fid = process.env.FID; + + if (!seedPhrase || !fid) { + return null; + } + + return { seedPhrase, fid }; +} + +export async function generateFarcasterMetadata() { + const appUrl = process.env.NEXT_PUBLIC_URL; + if (!appUrl) { + throw new Error('NEXT_PUBLIC_URL not configured'); + } + + // Get the domain from the URL (without https:// prefix) + const domain = new URL(appUrl).hostname; + console.log('Using domain for manifest:', domain); + + const secretEnvVars = getSecretEnvVars(); + if (!secretEnvVars) { + console.warn('No seed phrase or FID found in environment variables -- generating unsigned metadata'); + } + + let accountAssociation; + if (secretEnvVars) { + // Generate account from seed phrase + const account = mnemonicToAccount(secretEnvVars.seedPhrase); + const custodyAddress = account.address; + + const header = { + fid: parseInt(secretEnvVars.fid), + type: 'custody', + key: custodyAddress, + }; + const encodedHeader = Buffer.from(JSON.stringify(header), 'utf-8').toString('base64'); + + const payload = { + domain + }; + const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url'); + + const signature = await account.signMessage({ + message: `${encodedHeader}.${encodedPayload}` + }); + const encodedSignature = Buffer.from(signature, 'utf-8').toString('base64url'); + + accountAssociation = { + header: encodedHeader, + payload: encodedPayload, + signature: encodedSignature + }; + } + + return { + accountAssociation, + frame: { + version: "1", + name: process.env.NEXT_PUBLIC_FRAME_NAME || "Frames v2 Demo", + iconUrl: `${appUrl}/icon.png`, + homeUrl: appUrl, + imageUrl: `${appUrl}/opengraph-image`, + buttonTitle: process.env.NEXT_PUBLIC_FRAME_BUTTON_TEXT || "Launch Frame", + splashImageUrl: process.env.NEXT_PUBLIC_FRAME_SPLASH_IMAGE_URL || `${appUrl}/splash.png`, + splashBackgroundColor: "#f7f7f7", + webhookUrl: `${appUrl}/api/webhook`, + }, + }; +}