├── .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 |
40 | 41 | 42 |
43 |
44 | 45 |