mirror of
https://github.com/neynarxyz/create-farcaster-mini-app.git
synced 2025-12-08 02:12:34 -05:00
Add notification savings + webhook validation
This commit is contained in:
committed by
lucas-neynar
parent
af451b12a1
commit
8acf07b03e
62
src/lib/jfs.ts
Normal file
62
src/lib/jfs.ts
Normal 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
32
src/lib/kv.ts
Normal 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
34
src/lib/neynar.ts
Normal 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
65
src/lib/notifs.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user