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
9 changed files with 321 additions and 110 deletions

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