mirror of
https://github.com/neynarxyz/create-farcaster-mini-app.git
synced 2025-11-16 08:08:56 -05:00
Add notification savings + webhook validation
This commit is contained in:
parent
af451b12a1
commit
8acf07b03e
@ -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",
|
||||
|
||||
@ -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,34 +20,23 @@ 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) {
|
||||
if (sendResult.state === "error") {
|
||||
return Response.json(
|
||||
{ success: false, errors: responseBody.error.errors },
|
||||
{ success: false, error: sendResult.error },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fail when rate limited
|
||||
if (responseBody.data.result.rateLimitedTokens.length) {
|
||||
} else if (sendResult.state === "rate_limit") {
|
||||
return Response.json(
|
||||
{ success: false, error: "Rate limited" },
|
||||
{ status: 429 }
|
||||
@ -57,10 +44,4 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
} else {
|
||||
return Response.json(
|
||||
{ success: false, error: responseJson },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
let data;
|
||||
try {
|
||||
const verifySignatureResult = await verifyJsonFarcasterSignature(
|
||||
requestJson
|
||||
);
|
||||
if (verifySignatureResult.success === false) {
|
||||
return Response.json(
|
||||
{ success: false, errors: requestBody.error.errors },
|
||||
{ status: 400 }
|
||||
{ success: false, error: verifySignatureResult.error },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 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 }
|
||||
);
|
||||
data = verifySignatureResult;
|
||||
} catch {
|
||||
return Response.json({ success: false }, { status: 500 });
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
<div className="mb-2 text-sm">
|
||||
Add frame result: {addFrameResult}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={addFrame}>Add frame to client</Button>
|
||||
<Button onClick={addFrame} disabled={context?.client.added}>
|
||||
Add frame to client
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notificationDetails && (
|
||||
<div>
|
||||
<h2 className="font-2xl font-bold">Notify</h2>
|
||||
|
||||
{sendNotificationResult && (
|
||||
<div className="mb-2">
|
||||
<div className="mb-2 text-sm">
|
||||
Send notification result: {sendNotificationResult}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<Button onClick={sendNotification}>Send notification</Button>
|
||||
<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
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 };
|
||||
}
|
||||
}
|
||||
12
yarn.lock
12
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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user