├── .eslintrc.json
├── .gitignore
├── README.md
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── next.svg
└── vercel.svg
├── src
├── app
│ ├── AppConfig.ts
│ ├── Components
│ │ ├── Message.tsx
│ │ └── Web3Provider.tsx
│ ├── api
│ │ ├── [message]
│ │ │ ├── checkout
│ │ │ │ └── route.tsx
│ │ │ ├── render.ts
│ │ │ ├── reshare
│ │ │ │ └── route.tsx
│ │ │ └── route.tsx
│ │ └── og
│ │ │ └── [message]
│ │ │ └── route.tsx
│ ├── c
│ │ └── [message]
│ │ │ └── route.ts
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── new
│ │ ├── Form.tsx
│ │ ├── NavBar.tsx
│ │ ├── actions.ts
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── page.tsx
├── lib
│ ├── abis.ts
│ ├── farcaster.ts
│ ├── messages.ts
│ ├── unlock.ts
│ └── utils.ts
└── types.ts
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Token Gated Frames
2 |
3 | Check [Token Gated frames](https://frames.token-gated.com/) and get started by creating your own!
4 |
5 | ## Next Steps
6 |
7 | - [x] Let users create their own token-gated frames
8 | - [x] Support for ERC20
9 | - [x] Support for ERC1155
10 | - [ ] Add back support for Markdown (find why it breaks `ImageResponse` first!)
11 | - [ ] Let members reshare a token gated frame and [earn referral fees](https://unlock-protocol.com/blog/referral-fees?_gl=1*p7ybix*_ga*MTMyNTU2OTQxMC4xNzA2Mjk4NTQ4*_ga_DGDLJTEV6N*MTcwNjcxNDkyMi40LjAuMTcwNjcxNDkyMi4wLjAuMA..)
12 | - [ ] _META_ Let users create token-gated frames... from the frame!
13 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "token-gated-frame",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@tailwindcss/typography": "^0.5.10",
13 | "@tanstack/react-query": "^5.18.1",
14 | "@unlock-protocol/networks": "^0.0.20",
15 | "@vercel/og": "^0.6.2",
16 | "@vercel/postgres": "^0.7.2",
17 | "@vercel/postgres-kysely": "^0.7.2",
18 | "connectkit": "^1.7.0-demo",
19 | "daisyui": "^4.6.1",
20 | "kysely": "^0.27.2",
21 | "next": "14.1.0",
22 | "react": "^18",
23 | "react-dom": "^18",
24 | "react-markdown": "^9.0.1",
25 | "viem": "2.x",
26 | "wagmi": "^2.5.5"
27 | },
28 | "devDependencies": {
29 | "@types/node": "^20",
30 | "@types/react": "^18",
31 | "@types/react-dom": "^18",
32 | "autoprefixer": "^10.0.1",
33 | "eslint": "^8",
34 | "eslint-config-next": "14.1.0",
35 | "postcss": "^8",
36 | "tailwindcss": "^3.3.0",
37 | "typescript": "^5"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/AppConfig.ts:
--------------------------------------------------------------------------------
1 | export const AppConfig = {
2 | name: "Token Gated Frames",
3 | description: `A simple Farcaster Frame application that lets you create a token-gated cast. `,
4 | environment: process.env.NEXT_PUBLIC_VERCEL_ENV,
5 | siteUrl: process.env.NEXT_PUBLIC_URL_BASE || "http://localhost:3000",
6 | googleAnalyticsId: process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID!,
7 | hotjarId: Number(process.env.NEXT_PUBLIC_HOTJAR_ID!),
8 | } as const;
9 |
--------------------------------------------------------------------------------
/src/app/Components/Message.tsx:
--------------------------------------------------------------------------------
1 | //
2 | // Renders a message, any message!
3 | export const Message = ({ content }: { content: string }) => {
4 | const classes =
5 | "flex flex-wrap flex-col h-full justify-center w-[1200px] bg-white text-5xl p-10 items-center ";
6 | return (
7 |
8 | {content}
9 |
10 | );
11 | };
12 |
--------------------------------------------------------------------------------
/src/app/Components/Web3Provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { WagmiProvider, createConfig } from "wagmi";
3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4 | import { ConnectKitProvider, getDefaultConfig } from "connectkit";
5 | import { AppConfig } from "../AppConfig";
6 | import networks from "@unlock-protocol/networks";
7 | import { defineChain } from "viem";
8 |
9 | const config = createConfig(
10 | getDefaultConfig({
11 | // Your dApps chains
12 | // @ts-expect-error
13 | chains: Object.keys(networks).map((id: string) => {
14 | const network = networks[id];
15 | return defineChain({
16 | id: parseInt(id),
17 | name: network.name,
18 | nativeCurrency: network.nativeCurrency,
19 | rpcUrls: {
20 | default: {
21 | http: network.publicProvider,
22 | },
23 | },
24 | });
25 | }),
26 | transports: {
27 | // // RPC URL for each chain
28 | // [mainnet.id]: http(
29 | // `https://eth-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_ID}`
30 | // ),
31 | },
32 |
33 | // Required App Info
34 | appName: AppConfig.name,
35 |
36 | // Optional App Info
37 | appDescription: AppConfig.description,
38 | appUrl: AppConfig.siteUrl, // your app's url
39 | })
40 | );
41 |
42 | const queryClient = new QueryClient();
43 |
44 | export const Web3Provider = ({ children }: { children: React.ReactNode }) => {
45 | return (
46 |
47 |
48 | {children}
49 |
50 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/src/app/api/[message]/checkout/route.tsx:
--------------------------------------------------------------------------------
1 | import { getUserProfile, validateMessage } from "@/lib/farcaster";
2 | import { getMessage } from "@/lib/messages";
3 |
4 | export async function POST(
5 | request: Request,
6 | { params }: { params: { message: string } }
7 | ) {
8 | const body = await request.json();
9 | const { trustedData } = body;
10 |
11 | if (!trustedData) {
12 | return new Response("Missing trustedData", { status: 441 });
13 | }
14 | const fcMessage = await validateMessage(trustedData.messageBytes);
15 | if (!fcMessage.valid) {
16 | return new Response("Invalid message", { status: 442 });
17 | }
18 | const checkoutRedirect = new URL(request.url);
19 |
20 | try {
21 | // Get the message URL so we can then redirect to it!
22 | const posterProfile = await getUserProfile(
23 | fcMessage.message.data.frameActionBody?.castId?.fid
24 | );
25 | const userName = posterProfile.messages
26 | .sort((a: any, b: any) => a.data.timestamp > b.data.timestamp)
27 | .find((m: any) => {
28 | return (
29 | m.data.type === "MESSAGE_TYPE_USER_DATA_ADD" &&
30 | m.data.userDataBody.type === "USER_DATA_TYPE_USERNAME"
31 | );
32 | }).data.userDataBody.value;
33 | checkoutRedirect.searchParams.append(
34 | "cast",
35 | `https://warpcast.com/${userName}/${fcMessage.message.data.frameActionBody?.castId.hash}`
36 | );
37 | } catch (error) {
38 | console.error(
39 | `Could not build the redirect URL for ${JSON.stringify(
40 | fcMessage.message.data,
41 | null,
42 | 2
43 | )}`
44 | );
45 | console.error(error);
46 | }
47 | return Response.redirect(checkoutRedirect.toString(), 302);
48 | }
49 |
50 | export async function GET(
51 | request: Request,
52 | { params }: { params: { message: string } }
53 | ) {
54 | const message = await getMessage(params.message);
55 | if (!message) {
56 | return new Response("Message not found", { status: 404 });
57 | }
58 |
59 | const checkoutUrl = new URL(message.frame.checkoutUrl);
60 |
61 | const u = new URL(request.url);
62 | const cast = u.searchParams.get("cast");
63 | if (cast) {
64 | // We have a cast URL to redirect to!
65 | checkoutUrl.searchParams.append("redirect-url", cast!);
66 | }
67 |
68 | return Response.redirect(checkoutUrl.toString(), 302);
69 | }
70 |
--------------------------------------------------------------------------------
/src/app/api/[message]/render.ts:
--------------------------------------------------------------------------------
1 | import { getMessage } from "@/lib/messages";
2 | import { getUserAddresses } from "@/lib/farcaster";
3 | import { meetsRequirement } from "@/lib/unlock";
4 | import { getImage } from "@/lib/utils";
5 |
6 | interface Button {
7 | label: string;
8 | action: string;
9 | }
10 |
11 | export const renderMessageForFid = async (
12 | origin: string,
13 | messageId: string,
14 | fid?: string | null
15 | ) => {
16 | const message = await getMessage(messageId);
17 | if (!message) {
18 | return new Response("Message not found", { status: 404 });
19 | }
20 |
21 | if (!fid) {
22 | return render(origin, message, "pending", `${origin}/api/${message.id}/`, [
23 | {
24 | label: "Reveal 🔓",
25 | action: "post",
26 | },
27 | ]);
28 | }
29 |
30 | const addresses = await getUserAddresses(fid);
31 | if (addresses.length === 0) {
32 | return render(origin, message, "no-wallet");
33 | }
34 |
35 | const isMember = (
36 | await Promise.all(
37 | addresses.map((userAddress: string) => {
38 | return meetsRequirement(
39 | userAddress as `0x${string}`,
40 | message.frame.gate
41 | );
42 | })
43 | )
44 | ).some((balance) => !!balance);
45 |
46 | if (isMember) {
47 | // No action (yet!)
48 | return render(origin, message, "clear");
49 | } else if (message.frame.checkoutUrl) {
50 | // Show the checkout button
51 | return render(
52 | origin,
53 | message,
54 | "hidden",
55 | `${origin}/api/${message.id}/checkout`,
56 | [
57 | {
58 | label: "Get the tokens!",
59 | action: "post_redirect",
60 | },
61 | ]
62 | );
63 | } else {
64 | // No checkout button!
65 | return render(origin, message, "hidden");
66 | }
67 | };
68 |
69 | export const render = async (
70 | base: string,
71 | message: { id: string; author: string; frame: any },
72 | status: "pending" | "hidden" | "clear" | "no-wallet",
73 | postUrl?: string,
74 | buttons?: Button[]
75 | ) => {
76 | const image = getImage(base, message, status);
77 | return new Response(
78 | `
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | ${
96 | postUrl
97 | ? ` `
98 | : ``
99 | }
100 | ${(buttons || [])
101 | .map((button, i) => {
102 | return `
105 | `;
108 | })
109 | .join("\n")}
110 |
111 |
112 |
113 |
114 |
115 | Cast-it
118 |
119 |
120 | `,
121 | {
122 | headers: {
123 | "Content-Type": "text/html",
124 | },
125 | status: 200,
126 | }
127 | );
128 | };
129 |
--------------------------------------------------------------------------------
/src/app/api/[message]/reshare/route.tsx:
--------------------------------------------------------------------------------
1 | import { getMessage } from "@/lib/messages";
2 |
3 | export async function POST(
4 | request: Request,
5 | { params }: { params: { message: string } }
6 | ) {
7 | return Response.redirect(request.url, 302);
8 | }
9 |
10 | export async function GET(
11 | request: Request,
12 | { params }: { params: { message: string } }
13 | ) {
14 | const message = await getMessage(params.message);
15 | if (!message) {
16 | return new Response("Message not found", { status: 404 });
17 | }
18 |
19 | return Response.redirect(message.frame.checkoutUrl, 302);
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/api/[message]/route.tsx:
--------------------------------------------------------------------------------
1 | import { validateMessage } from "@/lib/farcaster";
2 | import { renderMessageForFid } from "./render";
3 |
4 | export async function POST(
5 | request: Request,
6 | { params }: { params: { message: string } }
7 | ) {
8 | const u = new URL(request.url);
9 | const body = await request.json();
10 | const { trustedData } = body;
11 |
12 | if (!trustedData) {
13 | return new Response("Missing trustedData", { status: 441 });
14 | }
15 | const fcMessage = await validateMessage(trustedData.messageBytes);
16 | if (!fcMessage.valid || !fcMessage.message.data.fid) {
17 | return new Response("Invalid message", { status: 442 });
18 | }
19 | return renderMessageForFid(
20 | u.origin,
21 | params.message,
22 | fcMessage.message.data.fid
23 | );
24 | }
25 |
26 | export async function GET(
27 | request: Request,
28 | { params }: { params: { message: string } }
29 | ) {
30 | const u = new URL(request.url);
31 | if (u.origin !== "http://localhost:3000") {
32 | return new Response("Invalid origin", { status: 443 });
33 | }
34 | const fid = u.searchParams.get("fid");
35 | return renderMessageForFid(u.origin, params.message, fid);
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/api/og/[message]/route.tsx:
--------------------------------------------------------------------------------
1 | import { Message } from "@/app/Components/Message";
2 | import { getMessage } from "@/lib/messages";
3 | import { ImageResponse } from "@vercel/og";
4 |
5 | export async function GET(
6 | request: Request,
7 | { params }: { params: { message: string } }
8 | ) {
9 | const u = new URL(request.url);
10 | let content = "";
11 | if (u.searchParams.get("state") === "no-wallet") {
12 | content = "Please link your farcaster account to a wallet!";
13 | } else {
14 | const message = await getMessage(params.message);
15 | if (!message) {
16 | return new Response("Message not found", { status: 404 });
17 | }
18 | content = message.frame.description;
19 | if (u.searchParams.get("state") === "clear") {
20 | content = message.frame.body;
21 | } else if (u.searchParams.get("state") === "hidden") {
22 | content =
23 | message.frame.denied || "You need to get a membership! Click below ⬇️";
24 | }
25 | }
26 | return new ImageResponse( , {
27 | width: 1200,
28 | height: 630,
29 | });
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/c/[message]/route.ts:
--------------------------------------------------------------------------------
1 | import { render } from "@/app/api/[message]/render";
2 | import { getMessage } from "@/lib/messages";
3 |
4 | export async function GET(
5 | request: Request,
6 | { params }: { params: { message: string } }
7 | ) {
8 | const u = new URL(request.url);
9 |
10 | const message = await getMessage(params.message);
11 | if (!message) {
12 | return new Response("Message not found", { status: 404 });
13 | }
14 |
15 | return render(
16 | u.origin,
17 | message,
18 | "pending",
19 | `${u.origin}/api/${message.id}/`,
20 | [
21 | {
22 | label: "Reveal 🔓",
23 | action: "post",
24 | },
25 | ]
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unlock-protocol/token-gated-frame/8d6994065121e99e1f5c87d9f452edbbc3368494/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | html {
20 | min-height: 100%;
21 | }
22 |
23 | body {
24 | color: rgb(var(--foreground-rgb));
25 | background: linear-gradient(
26 | to bottom,
27 | transparent,
28 | rgb(var(--background-end-rgb))
29 | )
30 | rgb(var(--background-start-rgb));
31 | min-height: 100%;
32 | }
33 |
34 | @layer utilities {
35 | .text-balance {
36 | text-wrap: balance;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 | import { AppConfig } from "./AppConfig";
5 | import Link from "next/link";
6 |
7 | const inter = Inter({ subsets: ["latin"] });
8 |
9 | export const metadata: Metadata = {
10 | title: AppConfig.name,
11 | description: AppConfig.description,
12 | };
13 |
14 | export default function RootLayout({
15 | children,
16 | }: Readonly<{
17 | children: React.ReactNode;
18 | }>) {
19 | return (
20 |
21 |
22 | {children}
23 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/app/new/Form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Frame } from "@/types";
3 | import networks from "@unlock-protocol/networks";
4 | import { createFrame } from "./actions";
5 | import { useFormState, useFormStatus } from "react-dom";
6 | import { useAccount } from "wagmi";
7 | import { useRouter } from "next/navigation";
8 | import { useEffect, useState } from "react";
9 | import Link from "next/link";
10 |
11 | export const Form = () => {
12 | const [showTokenId, setShowTokenId] = useState(false);
13 | const { push } = useRouter();
14 | const { address } = useAccount();
15 |
16 | // @ts-expect-error
17 | const [frame, formAction] = useFormState>(createFrame, {
18 | author: address,
19 | frame: {},
20 | });
21 | const status = useFormStatus();
22 |
23 | useEffect(() => {
24 | if (frame?.id) {
25 | return push(`/c/${frame.id}`);
26 | }
27 | }, [frame, push]);
28 |
29 | return (
30 |
31 |
32 | Please complete the following form! You can use any ERC721, ERC20 or
33 | ERC1155 contract, including Unlock Protocol's{" "}
34 |
35 | Membership contracts
36 | {" "}
37 | (they are ERC721 contracts on steroids).
38 |
39 |
175 |
176 | );
177 | };
178 |
--------------------------------------------------------------------------------
/src/app/new/NavBar.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { AppConfig } from "../AppConfig";
3 | import { ConnectKitButton } from "connectkit";
4 |
5 | export const Navbar = () => {
6 | return (
7 |
8 |
9 | {/*
*/}
49 |
50 |
{AppConfig.name}
51 |
52 |
53 | {/*
54 |
55 |
56 | Item 1
57 |
58 |
59 |
60 | Parent
61 |
69 |
70 |
71 |
72 | Item 3
73 |
74 |
75 |
*/}
76 |
77 |
78 | {" "}
79 |
80 |
81 | );
82 | };
83 |
--------------------------------------------------------------------------------
/src/app/new/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { FrameFields } from "@/types";
4 | import { Database } from "@/types";
5 | import { createKysely } from "@vercel/postgres-kysely";
6 |
7 | const db = createKysely();
8 |
9 | export async function createFrame(_prev: any, form: FormData) {
10 | // TODO: perform validation!
11 |
12 | const saved = await db
13 | .insertInto("frames")
14 | .values({
15 | // @ts-expect-error author should not be null
16 | author: form.get("author"),
17 | // @ts-expect-error frame format may not match, but it's json!
18 | frame: {
19 | title: form.get("frame.title"),
20 | description: form.get("frame.description"),
21 | body: form.get("frame.body"),
22 | denied: form.get("frame.denied"),
23 | gate: {
24 | network: Number(form.get("frame.gate.network")),
25 | type: form.get("frame.gate.type"),
26 | token: form.get("frame.gate.token"),
27 | balance: form.get("frame.gate.balance"),
28 | contract: form.get("frame.gate.contract"),
29 | },
30 | checkoutUrl: form.get("frame.checkoutUrl"),
31 | } as FrameFields,
32 | })
33 | .returning(["id"])
34 | .executeTakeFirst();
35 |
36 | return saved;
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/new/layout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Web3Provider } from "../Components/Web3Provider";
3 | import { Navbar } from "./NavBar";
4 |
5 | export default function NewLayout({
6 | children,
7 | }: Readonly<{
8 | children: React.ReactNode;
9 | }>) {
10 | return (
11 |
12 |
13 | {children}
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/new/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useAccount } from "wagmi";
3 | import { Form } from "./Form";
4 |
5 | const NotConnected = () => {
6 | return (
7 |
8 |
Please start by connecting a wallet.
9 |
10 | );
11 | };
12 |
13 | export default function NewFrame() {
14 | const { address } = useAccount();
15 |
16 | return (
17 | <>
18 | {!address && }
19 | {address && }
20 | >
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export default async function Home() {
4 | return (
5 |
6 |
Token Gated Frames
7 |
8 |
13 | Frames
14 | {" "}
15 | are an interation on top of{" "}
16 |
17 | OpenGraph
18 |
19 | .
20 |
21 |
22 | At{" "}
23 |
28 | Unlock
29 | {" "}
30 | we built a protocol for membership. You can now{" "}
31 | token-gate your frames so that only active members can see
32 | their content!
33 |
34 |
35 | Get Started
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/abis.ts:
--------------------------------------------------------------------------------
1 | export const ABIs = {
2 | ERC721: [
3 | {
4 | inputs: [{ internalType: "address", name: "_keyOwner", type: "address" }],
5 | name: "balanceOf",
6 | outputs: [{ internalType: "uint256", name: "balance", type: "uint256" }],
7 | stateMutability: "view",
8 | type: "function",
9 | },
10 | ],
11 | ERC20: [
12 | {
13 | inputs: [{ internalType: "address", name: "_keyOwner", type: "address" }],
14 | name: "balanceOf",
15 | outputs: [{ internalType: "uint256", name: "balance", type: "uint256" }],
16 | stateMutability: "view",
17 | type: "function",
18 | },
19 | {
20 | inputs: [],
21 | name: "decimals",
22 | outputs: [{ internalType: "uint256", name: "balance", type: "uint256" }],
23 | stateMutability: "view",
24 | type: "function",
25 | },
26 | ],
27 | ERC1155: [
28 | {
29 | inputs: [
30 | { internalType: "address", name: "_keyOwner", type: "address" },
31 | {
32 | internalType: "uint256",
33 | name: "token",
34 | type: "uint256",
35 | },
36 | ],
37 | name: "balanceOf",
38 | outputs: [{ internalType: "uint256", name: "balance", type: "uint256" }],
39 | stateMutability: "view",
40 | type: "function",
41 | },
42 | ],
43 | };
44 |
--------------------------------------------------------------------------------
/src/lib/farcaster.ts:
--------------------------------------------------------------------------------
1 | const endpoint = "https://nemes.farcaster.xyz:2281";
2 | const version = "v1";
3 |
4 | function hexToBytes(hex: string) {
5 | for (var bytes = [], c = 0; c < hex.length; c += 2) {
6 | bytes.push(parseInt(hex.substr(c, 2), 16));
7 | }
8 | return new Uint8Array(bytes);
9 | }
10 |
11 | export const validateMessage = async (message: string) => {
12 | const u = new URL(`${endpoint}/${version}/validateMessage`);
13 | const response = await fetch(u.toString(), {
14 | method: "POST",
15 | body: hexToBytes(message),
16 | headers: {
17 | "Content-Type": "application/octet-stream",
18 | },
19 | });
20 | return response.json();
21 | };
22 |
23 | export const getUserProfile = async (fid: string) => {
24 | const u = new URL(`${endpoint}/${version}/userDataByFid`);
25 | u.searchParams.append("fid", fid);
26 | const response = await fetch(u.toString());
27 | return response.json();
28 | };
29 |
30 | export const getUserAddresses = async (fid: string) => {
31 | const u = new URL(`${endpoint}/${version}/verificationsByFid`);
32 | u.searchParams.append("fid", fid);
33 | const response = await fetch(u.toString());
34 | const data = await response.json();
35 | return data.messages
36 | .filter((message: any) => {
37 | return message.data.type === "MESSAGE_TYPE_VERIFICATION_ADD_ETH_ADDRESS";
38 | })
39 | .map((message: any) => {
40 | return message.data.verificationAddEthAddressBody.address;
41 | });
42 | };
43 |
--------------------------------------------------------------------------------
/src/lib/messages.ts:
--------------------------------------------------------------------------------
1 | import { Database, Frame } from "@/types";
2 | import { createKysely } from "@vercel/postgres-kysely";
3 | import { UUID } from "crypto";
4 |
5 | const db = createKysely();
6 |
7 | export const getMessage = async (
8 | id: string
9 | ): Promise => {
10 | // Support for the legacy demo (used ints for id!)
11 | if (parseInt(id).toString() !== id) {
12 | const frame = await db
13 | .selectFrom("frames")
14 | .select(["frame", "id"])
15 | .where("id", "=", id as UUID)
16 | .executeTakeFirst();
17 | // @ts-expect-error
18 | return frame;
19 | }
20 |
21 | const paywallConfig = {
22 | pessimistic: true,
23 | persistentCheckout: true,
24 | title: "Unlock Community Membership",
25 | skipRecipient: true,
26 | locks: {
27 | "0xb77030a7e47a5eb942a4748000125e70be598632": {
28 | name: "Unlock Community",
29 | network: 137,
30 | },
31 | },
32 | metadataInputs: [{ name: "email", type: "email", required: true }],
33 | };
34 |
35 | return {
36 | // @ts-expect-error
37 | id: "1",
38 | frame: {
39 | title: "Some title",
40 | body: `👏 You're in the secret! 🤫.
41 |
42 | You can only view this if you own a valid membership NFT from the Unlock community!
43 |
44 | This is a token gated frame!
45 | `,
46 | description: "Are you a member of the Unlock Community? Click Reveal 🔓!",
47 | denied:
48 | "You are not a member of the Unlock Community. Click below to get the free token!",
49 | gate: {
50 | contract: "0xb77030a7e47a5eb942a4748000125e70be598632",
51 | network: 137,
52 | },
53 | checkoutUrl: `https://app.unlock-protocol.com/checkout?paywallConfig=${encodeURIComponent(
54 | JSON.stringify(paywallConfig)
55 | )}`,
56 | },
57 | };
58 | };
59 |
--------------------------------------------------------------------------------
/src/lib/unlock.ts:
--------------------------------------------------------------------------------
1 | import { createPublicClient, http } from "viem";
2 | import { ABIs } from "./abis";
3 |
4 | export const meetsRequirement = async (user: `0x${string}`, gate: any) => {
5 | const client = createPublicClient({
6 | transport: http(`https://rpc.unlock-protocol.com/${gate.network}`),
7 | });
8 |
9 | const abi = ABIs[(gate.type || "ERC721") as keyof typeof ABIs];
10 | const args = gate.type === "ERC1155" ? [user, gate.token] : [user];
11 | const balance = (await client.readContract({
12 | abi: abi,
13 | address: gate.contract,
14 | functionName: "balanceOf",
15 | args,
16 | })) as number;
17 | const requiredBalance =
18 | typeof gate.balance === "undefined" ? 1 : parseInt(gate.balance);
19 |
20 | if (gate.type === "ERC20") {
21 | // We need to get the decimals!
22 | const decimals = (await client.readContract({
23 | abi: abi,
24 | address: gate.contract,
25 | functionName: "decimals",
26 | })) as number;
27 | return balance >= requiredBalance * 10 ** Number(decimals);
28 | } else {
29 | return balance >= requiredBalance;
30 | }
31 | };
32 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | export const getImage = (
2 | base: string,
3 | message: { id: string },
4 | state = "pending"
5 | ) => {
6 | const u = new URL(`${base}/api/og/${message.id}`);
7 | if (state !== "pending") {
8 | u.searchParams.append("state", state);
9 | }
10 | return u.toString();
11 | };
12 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { UUID } from "crypto";
2 | import {
3 | ColumnType,
4 | Generated,
5 | Insertable,
6 | JSONColumnType,
7 | Selectable,
8 | Updateable,
9 | } from "kysely";
10 |
11 | export interface Database {
12 | frames: FrameTable;
13 | }
14 |
15 | export interface FrameFields {
16 | body: string;
17 | title: string;
18 | description: string;
19 | checkoutUrl: string;
20 | denied: string;
21 | gate: {
22 | contract: string;
23 | network: number;
24 | };
25 | }
26 |
27 | export interface FrameTable {
28 | id: Generated;
29 |
30 | author: string;
31 |
32 | createdAt: ColumnType;
33 | updatedAt: ColumnType;
34 |
35 | frame: JSONColumnType;
36 | }
37 |
38 | export type Frame = Selectable;
39 | export type NewFrame = Insertable;
40 | export type FrameUpdate = Updateable;
41 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | aspectRatio: {
12 | og: "1200 / 630",
13 | },
14 | },
15 | },
16 | plugins: [require("@tailwindcss/typography"), require("daisyui")],
17 | };
18 | export default config;
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------