diff --git a/package.json b/package.json index df95ad9..acb3419 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@farcaster/frame-sdk": "^0.0.14", "@tanstack/react-query": "^5.61.0", + "@upstash/redis": "^1.34.3", "next": "15.0.3", "react": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106", diff --git a/src/app/api/send-notification/route.ts b/src/app/api/send-notification/route.ts index 1b2f871..3e01348 100644 --- a/src/app/api/send-notification/route.ts +++ b/src/app/api/send-notification/route.ts @@ -1,14 +1,12 @@ -import { - SendNotificationRequest, - sendNotificationResponseSchema, -} from "@farcaster/frame-sdk"; +import { notificationDetailsSchema } from "@farcaster/frame-sdk"; import { NextRequest } from "next/server"; import { z } from "zod"; +import { setUserNotificationDetails } from "~/lib/kv"; +import { sendFrameNotification } from "~/lib/notifs"; const requestSchema = z.object({ - token: z.string(), - url: z.string(), - targetUrl: z.string(), + fid: z.number(), + notificationDetails: notificationDetailsSchema, }); export async function POST(request: NextRequest) { @@ -22,45 +20,28 @@ export async function POST(request: NextRequest) { ); } - const response = await fetch(requestBody.data.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - notificationId: crypto.randomUUID(), - title: "Hello from Frames v2!", - body: "This is a test notification", - targetUrl: requestBody.data.targetUrl, - tokens: [requestBody.data.token], - } satisfies SendNotificationRequest), + await setUserNotificationDetails( + requestBody.data.fid, + requestBody.data.notificationDetails + ); + + const sendResult = await sendFrameNotification({ + fid: requestBody.data.fid, + title: "Test notification", + body: "Sent at " + new Date().toISOString(), }); - const responseJson = await response.json(); - - if (response.status === 200) { - // Ensure correct response - const responseBody = sendNotificationResponseSchema.safeParse(responseJson); - if (responseBody.success === false) { - return Response.json( - { success: false, errors: responseBody.error.errors }, - { status: 500 } - ); - } - - // Fail when rate limited - if (responseBody.data.result.rateLimitedTokens.length) { - return Response.json( - { success: false, error: "Rate limited" }, - { status: 429 } - ); - } - - return Response.json({ success: true }); - } else { + if (sendResult.state === "error") { return Response.json( - { success: false, error: responseJson }, + { success: false, error: sendResult.error }, { status: 500 } ); + } else if (sendResult.state === "rate_limit") { + return Response.json( + { success: false, error: "Rate limited" }, + { status: 429 } + ); } + + return Response.json({ success: true }); } diff --git a/src/app/api/webhook/route.ts b/src/app/api/webhook/route.ts index db917d2..91aef3e 100644 --- a/src/app/api/webhook/route.ts +++ b/src/app/api/webhook/route.ts @@ -1,39 +1,35 @@ -import { - encodedJsonFarcasterSignatureSchema, - eventPayloadSchema, - jsonFarcasterSignatureHeaderSchema, -} from "@farcaster/frame-sdk"; +import { eventPayloadSchema } from "@farcaster/frame-sdk"; import { NextRequest } from "next/server"; +import { verifyJsonFarcasterSignature } from "~/lib/jfs"; +import { + deleteUserNotificationDetails, + setUserNotificationDetails, +} from "~/lib/kv"; +import { sendFrameNotification } from "~/lib/notifs"; export async function POST(request: NextRequest) { const requestJson = await request.json(); - const requestBody = - encodedJsonFarcasterSignatureSchema.safeParse(requestJson); - - if (requestBody.success === false) { - return Response.json( - { success: false, errors: requestBody.error.errors }, - { status: 400 } + let data; + try { + const verifySignatureResult = await verifyJsonFarcasterSignature( + requestJson ); + if (verifySignatureResult.success === false) { + return Response.json( + { success: false, error: verifySignatureResult.error }, + { status: 401 } + ); + } + + data = verifySignatureResult; + } catch { + return Response.json({ success: false }, { status: 500 }); } - // TODO: verify signature - - const headerData = JSON.parse( - Buffer.from(requestBody.data.header, "base64url").toString("utf-8") - ); - const header = jsonFarcasterSignatureHeaderSchema.safeParse(headerData); - if (header.success === false) { - return Response.json( - { success: false, errors: header.error.errors }, - { status: 400 } - ); - } - const fid = header.data.fid; - + const fid = data.fid; const payloadData = JSON.parse( - Buffer.from(requestBody.data.payload, "base64url").toString("utf-8") + Buffer.from(data.payload, "base64url").toString("utf-8") ); const payload = eventPayloadSchema.safeParse(payloadData); @@ -46,26 +42,34 @@ export async function POST(request: NextRequest) { switch (payload.data.event) { case "frame_added": - console.log( - payload.data.notificationDetails - ? `Got frame-added event for fid ${fid} with notification token ${payload.data.notificationDetails.token} and url ${payload.data.notificationDetails.url}` - : `Got frame-added event for fid ${fid} with no notification details` - ); + if (payload.data.notificationDetails) { + await setUserNotificationDetails(fid, payload.data.notificationDetails); + await sendFrameNotification({ + fid, + title: "Welcome to Frames v2", + body: "Frame is now added to your client", + }); + } else { + await deleteUserNotificationDetails(fid); + } + break; case "frame_removed": - console.log(`Got frame-removed event for fid ${fid}`); + await deleteUserNotificationDetails(fid); + break; case "notifications_enabled": - console.log( - `Got notifications-enabled event for fid ${fid} with token ${ - payload.data.notificationDetails.token - } and url ${payload.data.notificationDetails.url} ${JSON.stringify( - payload.data - )}` - ); + await setUserNotificationDetails(fid, payload.data.notificationDetails); + await sendFrameNotification({ + fid, + title: "Ding ding ding", + body: "Notifications are now enabled", + }); + break; case "notifications_disabled": - console.log(`Got notifications-disabled event for fid ${fid}`); + await deleteUserNotificationDetails(fid); + break; } diff --git a/src/components/Demo.tsx b/src/components/Demo.tsx index eec3387..0a7e76e 100644 --- a/src/components/Demo.tsx +++ b/src/components/Demo.tsx @@ -35,6 +35,10 @@ export default function Demo( useState(null); const [sendNotificationResult, setSendNotificationResult] = useState(""); + useEffect(() => { + setNotificationDetails(context?.client.notificationDetails ?? null); + }, [context]); + const { address, isConnected } = useAccount(); const chainId = useChainId(); @@ -74,7 +78,7 @@ export default function Demo( useEffect(() => { const load = async () => { setContext(await sdk.context); - sdk.actions.ready(); + sdk.actions.ready({}); }; if (sdk && !isSDKLoaded) { setIsSDKLoaded(true); @@ -96,7 +100,6 @@ export default function Demo( const addFrame = useCallback(async () => { try { - // setAddFrameResult(""); setNotificationDetails(null); const result = await sdk.actions.addFrame(); @@ -120,7 +123,7 @@ export default function Demo( const sendNotification = useCallback(async () => { setSendNotificationResult(""); - if (!notificationDetails) { + if (!notificationDetails || !context) { return; } @@ -130,15 +133,17 @@ export default function Demo( mode: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - token: notificationDetails.token, - url: notificationDetails.url, - targetUrl: window.location.href, + fid: context.user.fid, + notificationDetails, }), }); if (response.status === 200) { setSendNotificationResult("Success"); return; + } else if (response.status === 429) { + setSendNotificationResult("Rate limited"); + return; } const data = await response.text(); @@ -146,7 +151,7 @@ export default function Demo( } catch (error) { setSendNotificationResult(`Error: ${error}`); } - }, [notificationDetails]); + }, [context, notificationDetails]); const sendTx = useCallback(() => { sendTransaction( @@ -246,6 +251,20 @@ export default function Demo( + + +
+

Add to client & notifications

+ +
+ Client fid {context?.client.clientFid}, + {context?.client.added + ? " frame added to client," + : " frame not added to client,"} + {notificationDetails + ? " notifications enabled" + : " notifications disabled"} +
@@ -254,26 +273,26 @@ export default function Demo(
{addFrameResult && ( -
Add frame result: {addFrameResult}
- )} - -
-
- - {notificationDetails && ( -
-

Notify

- - {sendNotificationResult && ( -
- Send notification result: {sendNotificationResult} +
+ Add frame result: {addFrameResult}
)} -
- -
+
- )} + + {sendNotificationResult && ( +
+ Send notification result: {sendNotificationResult} +
+ )} +
+ +
+

Wallet

@@ -461,8 +480,10 @@ function SendEth() { const renderError = (error: Error | null) => { if (!error) return null; if (error instanceof BaseError) { - const isUserRejection = error.walk((e) => e instanceof UserRejectedRequestError) - + const isUserRejection = error.walk( + (e) => e instanceof UserRejectedRequestError + ); + if (isUserRejection) { return
Rejected by user.
; } @@ -470,4 +491,3 @@ const renderError = (error: Error | null) => { return
{error.message}
; }; - diff --git a/src/lib/jfs.ts b/src/lib/jfs.ts new file mode 100644 index 0000000..eb199f8 --- /dev/null +++ b/src/lib/jfs.ts @@ -0,0 +1,62 @@ +import { ed25519 } from "@noble/curves/ed25519"; +import { + encodedJsonFarcasterSignatureSchema, + jsonFarcasterSignatureHeaderSchema, +} from "@farcaster/frame-sdk"; +import { isSignerValid } from "~/lib/neynar"; + +type VerifyJsonFarcasterSignatureResult = + | { success: false; error: unknown } + | { success: true; fid: number; payload: string }; + +export async function verifyJsonFarcasterSignature( + data: unknown +): Promise { + // Parse & decode + const body = encodedJsonFarcasterSignatureSchema.safeParse(data); + if (body.success === false) { + return { success: false, error: body.error.errors }; + } + + const headerData = JSON.parse( + Buffer.from(body.data.header, "base64url").toString("utf-8") + ); + const header = jsonFarcasterSignatureHeaderSchema.safeParse(headerData); + if (header.success === false) { + return { success: false, error: header.error.errors }; + } + + const signature = Buffer.from(body.data.signature, "base64url"); + if (signature.byteLength !== 64) { + return { success: false, error: "Invalid signature length" }; + } + + const fid = header.data.fid; + const key = header.data.key; + + // Verify that the signer belongs to the FID + try { + const validSigner = await isSignerValid({ + fid, + signerPublicKey: key, + }); + if (!validSigner) { + return { success: false, error: "Invalid signer" }; + } + } catch { + return { success: false, error: "Error verifying signer" }; + } + + const signedInput = new Uint8Array( + Buffer.from(body.data.header + "." + body.data.payload) + ); + + const keyBytes = Uint8Array.from(Buffer.from(key.slice(2), "hex")); + + const verifyResult = ed25519.verify(signature, signedInput, keyBytes); + if (!verifyResult) { + return { success: false, error: "Invalid signature" }; + } + + return { success: true, fid, payload: body.data.payload }; +} diff --git a/src/lib/kv.ts b/src/lib/kv.ts new file mode 100644 index 0000000..ad58839 --- /dev/null +++ b/src/lib/kv.ts @@ -0,0 +1,32 @@ +import { FrameNotificationDetails } from "@farcaster/frame-sdk"; +import { Redis } from "@upstash/redis"; + +const redis = new Redis({ + url: process.env.KV_REST_API_URL, + token: process.env.KV_REST_API_TOKEN, +}); + +function getUserNotificationDetailsKey(fid: number): string { + return `frames-v2-demo:user:${fid}`; +} + +export async function getUserNotificationDetails( + fid: number +): Promise { + return await redis.get( + getUserNotificationDetailsKey(fid) + ); +} + +export async function setUserNotificationDetails( + fid: number, + notificationDetails: FrameNotificationDetails +): Promise { + await redis.set(getUserNotificationDetailsKey(fid), notificationDetails); +} + +export async function deleteUserNotificationDetails( + fid: number +): Promise { + await redis.del(getUserNotificationDetailsKey(fid)); +} diff --git a/src/lib/neynar.ts b/src/lib/neynar.ts new file mode 100644 index 0000000..772d29b --- /dev/null +++ b/src/lib/neynar.ts @@ -0,0 +1,34 @@ +const apiKey = process.env.NEYNAR_API_KEY || ""; + +export async function isSignerValid({ + fid, + signerPublicKey, +}: { + fid: number; + signerPublicKey: string; +}): Promise { + const url = new URL("https://hub-api.neynar.com/v1/onChainSignersByFid"); + url.searchParams.append("fid", fid.toString()); + + const response = await fetch(url, { + method: "GET", + headers: { + "x-api-key": apiKey, + }, + }); + + if (response.status !== 200) { + throw new Error(await response.text()); + } + + const responseJson = await response.json(); + const signerPublicKeyLC = signerPublicKey.toLowerCase(); + + const signerExists = responseJson.events.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (event: any) => + event.signerEventBody.key.toLowerCase() === signerPublicKeyLC + ); + + return signerExists; +} diff --git a/src/lib/notifs.ts b/src/lib/notifs.ts new file mode 100644 index 0000000..3789d0a --- /dev/null +++ b/src/lib/notifs.ts @@ -0,0 +1,65 @@ +import { + SendNotificationRequest, + sendNotificationResponseSchema, +} from "@farcaster/frame-sdk"; +import { getUserNotificationDetails } from "~/lib/kv"; + +const appUrl = process.env.NEXT_PUBLIC_URL || ""; + +type SendFrameNotificationResult = + | { + state: "error"; + error: unknown; + } + | { state: "no_token" } + | { state: "rate_limit" } + | { state: "success" }; + +export async function sendFrameNotification({ + fid, + title, + body, +}: { + fid: number; + title: string; + body: string; +}): Promise { + const notificationDetails = await getUserNotificationDetails(fid); + if (!notificationDetails) { + return { state: "no_token" }; + } + + const response = await fetch(notificationDetails.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + notificationId: crypto.randomUUID(), + title, + body, + targetUrl: appUrl, + tokens: [notificationDetails.token], + } satisfies SendNotificationRequest), + }); + + const responseJson = await response.json(); + + if (response.status === 200) { + const responseBody = sendNotificationResponseSchema.safeParse(responseJson); + if (responseBody.success === false) { + // Malformed response + return { state: "error", error: responseBody.error.errors }; + } + + if (responseBody.data.result.rateLimitedTokens.length) { + // Rate limited + return { state: "rate_limit" }; + } + + return { state: "success" }; + } else { + // Error response + return { state: "error", error: responseJson }; + } +} diff --git a/yarn.lock b/yarn.lock index ca24fdd..65c4107 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1175,6 +1175,13 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== +"@upstash/redis@^1.34.3": + version "1.34.3" + resolved "https://registry.yarnpkg.com/@upstash/redis/-/redis-1.34.3.tgz#df0338f4983bba5141878e851be4fced494b44a0" + integrity sha512-VT25TyODGy/8ljl7GADnJoMmtmJ1F8d84UXfGonRRF8fWYJz7+2J6GzW+a6ETGtk4OyuRTt7FRSvFG5GvrfSdQ== + dependencies: + crypto-js "^4.2.0" + "@wagmi/connectors@5.5.0": version "5.5.0" resolved "https://registry.yarnpkg.com/@wagmi/connectors/-/connectors-5.5.0.tgz#94bf6730dfea0032426230cd45b49183ccefb714" @@ -1949,6 +1956,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: dependencies: uncrypto "^0.1.3" +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + cssesc@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"