Add notification savings + webhook validation

This commit is contained in:
Christian Mladenov 2024-12-10 08:31:18 -08:00 committed by lucas-neynar
parent af451b12a1
commit 8acf07b03e
No known key found for this signature in database
9 changed files with 321 additions and 110 deletions

View File

@ -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",

View File

@ -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 });
}

View File

@ -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;
}

View File

@ -35,6 +35,10 @@ export default function Demo(
useState<FrameNotificationDetails | null>(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(
</div>
<Button onClick={close}>Close Frame</Button>
</div>
</div>
<div>
<h2 className="font-2xl font-bold">Add to client & notifications</h2>
<div className="mt-2 mb-4 text-sm">
Client fid {context?.client.clientFid},
{context?.client.added
? " frame added to client,"
: " frame not added to client,"}
{notificationDetails
? " notifications enabled"
: " notifications disabled"}
</div>
<div className="mb-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
@ -254,26 +273,26 @@ export default function Demo(
</pre>
</div>
{addFrameResult && (
<div className="mb-2">Add frame result: {addFrameResult}</div>
)}
<Button onClick={addFrame}>Add frame to client</Button>
</div>
</div>
{notificationDetails && (
<div>
<h2 className="font-2xl font-bold">Notify</h2>
{sendNotificationResult && (
<div className="mb-2">
Send notification result: {sendNotificationResult}
<div className="mb-2 text-sm">
Add frame result: {addFrameResult}
</div>
)}
<div className="mb-4">
<Button onClick={sendNotification}>Send notification</Button>
</div>
<Button onClick={addFrame} disabled={context?.client.added}>
Add frame to client
</Button>
</div>
)}
{sendNotificationResult && (
<div className="mb-2 text-sm">
Send notification result: {sendNotificationResult}
</div>
)}
<div className="mb-4">
<Button onClick={sendNotification} disabled={!notificationDetails}>
Send notification
</Button>
</div>
</div>
<div>
<h2 className="font-2xl font-bold">Wallet</h2>
@ -461,7 +480,9 @@ 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 <div className="text-red-500 text-xs mt-1">Rejected by user.</div>;
@ -470,4 +491,3 @@ const renderError = (error: Error | null) => {
return <div className="text-red-500 text-xs mt-1">{error.message}</div>;
};

62
src/lib/jfs.ts Normal file
View File

@ -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<VerifyJsonFarcasterSignatureResult> {
// 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 };
}

32
src/lib/kv.ts Normal file
View File

@ -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<FrameNotificationDetails | null> {
return await redis.get<FrameNotificationDetails>(
getUserNotificationDetailsKey(fid)
);
}
export async function setUserNotificationDetails(
fid: number,
notificationDetails: FrameNotificationDetails
): Promise<void> {
await redis.set(getUserNotificationDetailsKey(fid), notificationDetails);
}
export async function deleteUserNotificationDetails(
fid: number
): Promise<void> {
await redis.del(getUserNotificationDetailsKey(fid));
}

34
src/lib/neynar.ts Normal file
View File

@ -0,0 +1,34 @@
const apiKey = process.env.NEYNAR_API_KEY || "";
export async function isSignerValid({
fid,
signerPublicKey,
}: {
fid: number;
signerPublicKey: string;
}): Promise<boolean> {
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;
}

65
src/lib/notifs.ts Normal file
View File

@ -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<SendFrameNotificationResult> {
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 };
}
}

View File

@ -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"