├── .example.env.local ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── app ├── Passport.tsx ├── admin │ ├── Admin.tsx │ └── page.tsx ├── components │ └── analytics.tsx ├── globals.css ├── head.tsx ├── layout.tsx ├── page.module.css ├── page.tsx └── styles.tsx ├── exm ├── deploy.js ├── functionId.js ├── handler.js └── read.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages └── api │ ├── get-users.ts │ ├── post.ts │ └── set-nonce.ts ├── public ├── favicon.ico ├── next.svg ├── spinner.svg ├── thirteen.svg └── vercel.svg ├── tsconfig.json └── yarn.lock /.example.env.local: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_GC_API_KEY= 2 | NEXT_PUBLIC_GC_COMMUNITY_ID= 3 | EXM_API_KEY= 4 | NEXT_PUBLIC_THRESHOLD= -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.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 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Sybil Resistant Forms with Gitcoin Passport, EXM, and Next.js 2 | 3 | This is full stack forkable project for building Sybil-resistant forms, using the 4 | [Gitcoin Passport](https://passport.gitcoin.co/) to score users for your app and implement a secure, sybil-resistant, form and [EXM](https://exm.dev/) (a protocol built on [Arweave](https://www.arweave.org/)) for storing the form data. 5 | 6 | Gitcoin Passport is a tool that enables developers to build Sybil resistant 7 | applications while preserving privacy. The [Scorer 8 | API](https://scorer.gitcoin.co/) used in this example gives developers an easy 9 | way of retrieving a wallet's Passport score. 10 | 11 | ### Project Overview 12 | 13 | This app consists of two pages: 14 | 15 | 1. The landing page (`/`) will allow users to access the form if they have met the threshold score you require in your app, and they will then be able to submit their form entry. In the server routes (`pages/api/post`, `pages/api/set-nonce`) they will be verified again using a wallet signature and a nonce to verify their identity before being able to post. 16 | 17 | 2. The Admin route (`/admin`) will allow a whitelisted array of admins to be able to access the form data securely. If they are an admin, they will be able to view the results, if they are not, they will not be allowed to view the results. 18 | 19 | ### Getting started 20 | 21 | 1. Gitcon Passport API variables 22 | 23 | To get started, you must first create an environment variable and community using the [Gitcoin Scorer API](https://scorer.gitcoin.co/). 24 | 25 | You can look through this codebase to see what a simple integration with Gitcoin Passport looks like. For more detailed information [check out the documentation](https://docs.passport.gitcoin.co/). 26 | 27 | 2. EXM API Key 28 | 29 | You also need to create an [EXM API Key](https://exm.dev/app) and have it ready for the next steps. 30 | 31 | ### Running the app 32 | 33 | 1. Clone the repo and install the dependencies: 34 | 35 | ```sh 36 | git clone git@github.com:dabit3/nextjs-gitcoin-passport.git 37 | 38 | cd nextjs-gitcoin-passport 39 | 40 | npm install 41 | ``` 42 | 43 | 2. Configure the environment variables for your: 44 | 45 | a. Gitcoin Community ID 46 | b. Gitcoin API Key 47 | c. EXM API Key 48 | d. Minimum score for your form 49 | 50 | In a file named `.env.local`. (see example configuration at 51 | `.example.env.local`) 52 | 53 | ``` 54 | NEXT_PUBLIC_GC_API_KEY= 55 | NEXT_PUBLIC_GC_COMMUNITY_ID= 56 | EXM_API_KEY= 57 | NEXT_PUBLIC_THRESHOLD= 58 | ``` 59 | 60 | 3. Deploy the EXM function 61 | 62 | ```sh 63 | export EXM_API_KEY= 64 | 65 | node deploy.js 66 | ``` 67 | 68 | 4. Run the app 69 | 70 | ```sh 71 | npm run dev 72 | ``` 73 | 74 | ### Next Steps 75 | 76 | Once you've gotten a handle on how the integration works, check out some of the 77 | following links for more information on how to integrate Gitcoin Passport into 78 | your own application. 79 | 80 | - [Official Documentation](https://docs.passport.gitcoin.co/) 81 | - [Official Website](https://go.gitcoin.co/passport?utm_source=awesome-passports&utm_medium=referral&utm_content=Passport) 82 | - [Twitter Account](https://twitter.com/gitcoinpassport) 83 | 84 | ### Getting Involved 85 | 86 | If you're interested in getting involved, join Gitcoin's 87 | [Discord](https://gitcoin.co/discord) and look for the [🛠passport-builders 88 | channel](https://discord.com/channels/562828676480237578/986222591096279040). 89 | 90 | -------------------------------------------------------------------------------- /app/Passport.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useState, useEffect } from 'react' 3 | import { styles } from './styles' 4 | import { ConnectButton } from '@rainbow-me/rainbowkit' 5 | import { useAccount, useSigner } from 'wagmi' 6 | 7 | const API_KEY = process.env.NEXT_PUBLIC_GC_API_KEY 8 | const COMMUNITY_ID = process.env.NEXT_PUBLIC_GC_COMMUNITY_ID 9 | // score needed to see hidden message 10 | const THRESHOLD:number = Number(process.env.NEXT_PUBLIC_THRESHOLD) 11 | 12 | const headers = API_KEY ? ({ 13 | 'Content-Type': 'application/json', 14 | 'X-API-Key': API_KEY 15 | }) : undefined 16 | 17 | // submitting passport 18 | const SUBMIT_PASSPORT_URI = 'https://api.scorer.gitcoin.co/registry/submit-passport' 19 | // getting the signing message 20 | const SIGNING_MESSAGE_URI = 'https://api.scorer.gitcoin.co/registry/signing-message' 21 | 22 | export default function Passport() { 23 | const [score, setScore] = useState('') 24 | const [noScoreMessage, setNoScoreMessage] = useState('') 25 | const [formData, setFormData] = useState({}) 26 | const [processing, setProcessing] = useState(false) 27 | const [submittingPassport, setSubmittingPassport] = useState(false) 28 | const [showSuccessMessage, setShowSuccessMessage] = useState(false) 29 | 30 | const { address, isConnected } = useAccount() 31 | const { data: signer } = useSigner() 32 | 33 | useEffect(() => { 34 | if (isConnected) { 35 | checkPassport() 36 | } 37 | }, [isConnected]) 38 | 39 | 40 | async function checkPassport(currentAddress = address) { 41 | setScore('') 42 | setNoScoreMessage('') 43 | const GET_PASSPORT_SCORE_URI = `https://api.scorer.gitcoin.co/registry/score/${COMMUNITY_ID}/${currentAddress}` 44 | try { 45 | const response = await fetch(GET_PASSPORT_SCORE_URI, { 46 | headers 47 | }) 48 | const passportData = await response.json() 49 | if (passportData.score) { 50 | const roundedScore = Math.round(passportData.score * 100) / 100 51 | setScore(roundedScore.toString()) 52 | setSubmittingPassport(false) 53 | } else { 54 | console.log('No score available, please add stamps to your passport and then resubmit.') 55 | setSubmittingPassport(false) 56 | setNoScoreMessage('No score available, please submit your passport after you have added some stamps.') 57 | } 58 | } catch (err) { 59 | console.log('error: ', err) 60 | } 61 | } 62 | 63 | async function getSigningMessage() { 64 | try { 65 | const response = await fetch(SIGNING_MESSAGE_URI, { 66 | headers 67 | }) 68 | const json = await response.json() 69 | return json 70 | } catch (err) { 71 | console.log('error: ', err) 72 | } 73 | } 74 | 75 | async function submitPassport() { 76 | if (!signer) return 77 | try { 78 | const { message, nonce } = await getSigningMessage() 79 | const signature = await signer.signMessage(message) 80 | setNoScoreMessage('') 81 | setSubmittingPassport(true) 82 | setScore('') 83 | setNoScoreMessage('') 84 | await fetch(SUBMIT_PASSPORT_URI, { 85 | method: 'POST', 86 | headers, 87 | body: JSON.stringify({ 88 | address, 89 | community: COMMUNITY_ID, 90 | signature, 91 | nonce 92 | }) 93 | }) 94 | checkPassportStatus() 95 | } catch (err) { 96 | setNoScoreMessage('Please try resubmitting your passport and re-checking your score.') 97 | console.log('error: ', err) 98 | } 99 | } 100 | 101 | async function checkPassportStatus() { 102 | const GET_PASSPORT_SCORE_URI = `https://api.scorer.gitcoin.co/registry/score/${COMMUNITY_ID}/${address}` 103 | try { 104 | const response = await fetch(GET_PASSPORT_SCORE_URI, { 105 | headers 106 | }) 107 | const passportData = await response.json() 108 | console.log('passportData: ', passportData) 109 | if (passportData.status === 'PROCESSING') { 110 | await wait() 111 | return checkPassportStatus() 112 | } else { 113 | checkPassport() 114 | } 115 | } catch (err) { 116 | console.log('error: ', err) 117 | } 118 | } 119 | 120 | async function submit() { 121 | setShowSuccessMessage(false) 122 | setProcessing(true) 123 | const response = await fetch('/api/set-nonce', { 124 | method: 'POST', 125 | body: JSON.stringify({ 126 | address 127 | }) 128 | }) 129 | const json = await response.json() 130 | await post(json.nonce) 131 | } 132 | 133 | async function post(nonce) { 134 | if (!signer) return 135 | // the /post endpoint will verify the user's identity, and only post if they were indeed the wallet owner 136 | const signature = await signer.signMessage(nonce) 137 | const response = await fetch(`/api/post`, { 138 | method: 'POST', 139 | body: JSON.stringify({ 140 | signature, 141 | address, 142 | formData 143 | }) 144 | }) 145 | 146 | const json = await response.json() 147 | setProcessing(false) 148 | if (!json.error) { 149 | setShowSuccessMessage(true) 150 | } 151 | } 152 | 153 | return ( 154 |
155 |

SYBIL FORM

156 |

Gitcoin Passport is an identity protocol that proves your trustworthiness without needing to collect personally identifiable information. Configure your passport here

157 |
158 | { 159 | !isConnected && ( 160 | 161 | ) 162 | } 163 | { 164 | submittingPassport && ( 165 |

Please wait, submitting passport for new scoring ...

166 | ) 167 | } 168 | { 169 | score && ( 170 |
171 | { 172 | Number(score) > THRESHOLD &&

Your passport score is {score}, congratulations you are eligible!

173 | } 174 |
175 | { 176 | Number(score) < THRESHOLD && ( 177 | <> 178 |

Sorry, your score is {Number(score)}, it is not high enough to join the allow-list.

179 |
180 |

INCREASE YOUR SCORE:

181 |

✅ Contribute to Gitcoin Grants

182 |

🐦 Link a Twitter Profile

183 |

🧑‍💻 Link a Github Account

184 |

🔢 Verify ENS Ownership

185 |

🫡 Verify Proof of Humanity

186 |

🌿 Connect your Lens account

187 |
188 |
189 | 190 | 191 |
192 | 193 | ) 194 | } 195 |
196 |
197 | ) 198 | } 199 | { 200 | Number(score) >= THRESHOLD && ( 201 | <> 202 |
203 | setFormData({ ...formData, twitter: e.target.value })} 205 | placeholder='Twitter handle' 206 | style={styles.input} 207 | /> 208 | setFormData({ ...formData, github: e.target.value })} 210 | placeholder='Github handle' 211 | style={styles.input} 212 | /> 213 | setFormData({ ...formData, interests: e.target.value })} 215 | placeholder='Interests' 216 | style={styles.input} 217 | /> 218 |
219 | { 220 | showSuccessMessage && ( 221 |

Congratulations, you're now on the waitlist! ⚡️💅🔥

228 | ) 229 | } 230 | { 231 | processing && ( 232 |
233 | 234 |

Processing your submission....

235 |
236 | ) 237 | } 238 | { 239 | !processing && !showSuccessMessage && ( 240 | 241 | ) 242 | } 243 | 244 | ) 245 | } 246 | { 247 | noScoreMessage && ( 248 |
249 |

{noScoreMessage}

250 | 251 | 252 |
253 | ) 254 | } 255 |
256 |
257 | ) 258 | } 259 | 260 | function wait() { 261 | return new Promise((resolve) => setTimeout(resolve, 1000)) 262 | } 263 | 264 | // async function getScorer() { 265 | // // api scorer 266 | // const COMMUNITY_SCORER_URI = `https://api.scorer.gitcoin.co/registry/score/${COMMUNITY_ID}` 267 | // try { 268 | // const response = await fetch(COMMUNITY_SCORER_URI, { 269 | // headers 270 | // }) 271 | // const data = await response.json() 272 | // console.log('data: ', data) 273 | // } catch (err) { 274 | // console.log('error: ', err) 275 | // } 276 | // } -------------------------------------------------------------------------------- /app/admin/Admin.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import { styles } from '../styles' 5 | import { ConnectButton } from '@rainbow-me/rainbowkit' 6 | import { useAccount, useSigner } from 'wagmi' 7 | 8 | export default function Admin(props) { 9 | let [users, setUsers] = useState([]) 10 | const [fetching, setFetching] = useState(false) 11 | const { data: signer } = useSigner() 12 | const { address, isConnected } = useAccount() 13 | 14 | async function fetchUsers() { 15 | if (!signer) return 16 | try { 17 | setFetching(true) 18 | let response = await fetch('/api/set-nonce', { 19 | method: 'POST', 20 | body: JSON.stringify({ 21 | address 22 | }) 23 | }) 24 | let json = await response.json() 25 | const signature = await signer.signMessage(json.nonce) 26 | let postData = await fetch(`/api/get-users`, { 27 | method: 'POST', 28 | body: JSON.stringify({ 29 | signature, 30 | address, 31 | }) 32 | }) 33 | 34 | const userData = await postData.json() 35 | console.log('userData:', userData) 36 | setFetching(false) 37 | if (userData.users) { 38 | setUsers(userData.users) 39 | } 40 | } catch (err) { 41 | console.log('error fetching uses: ', err) 42 | setFetching(false) 43 | } 44 | } 45 | 46 | users = users.filter(u => u.formData) 47 | 48 | return ( 49 |
50 |

Admin

51 | { 52 | fetching &&

Loading users...

53 | } 54 | { 55 | isConnected && !fetching && () 56 | } 57 | { 58 | !isConnected && () 59 | } 60 |
61 | { 62 | Boolean(users.length) && ( 63 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 |
AddressTwitterGitHubInterestsScore
78 | ) 79 | } 80 | 85 | 86 | { 87 | users.map((user, index) => ( 88 | 89 | 94 | 98 | 102 | 103 | 104 | 105 | )) 106 | } 107 | 108 |
90 | {user.address.substring(0, 10)}.... 93 | 95 | {user.formData.twitter} 99 | {user.formData.github}

{user.formData.interests}

{Math.round(user.score)}

109 |
110 |
111 | ) 112 | } 113 | 114 | const thStyle = { 115 | textAlign: 'left' as 'left', 116 | width: '188px', 117 | padding: '10px', 118 | 119 | } 120 | 121 | const tdStyle = { 122 | textAlign: 'left' as 'left', 123 | width: '188px', 124 | padding: '10px', 125 | } 126 | 127 | const tableContainerStyle = { 128 | marginTop: '20px' 129 | } 130 | 131 | const headerContentStyle = { 132 | fontWeight: 'bold' 133 | } 134 | 135 | const linkStyle = { 136 | color: '#006cff' 137 | } 138 | 139 | const headingContainerStyle = { 140 | width: '100%', 141 | padding: '20px', 142 | } 143 | 144 | const userContainerStyle = { 145 | borderBottom: '1px solid rgba(0, 0, 0, .15)', 146 | backgroundColor: 'rgba(0, 0, 0, .075)' 147 | } -------------------------------------------------------------------------------- /app/admin/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import dynamic from "next/dynamic" 3 | 4 | const Admin = dynamic( 5 | () => import("./Admin").then((res) => res.default), 6 | { 7 | ssr: false, 8 | } 9 | ) 10 | 11 | export default function Home() { 12 | return ( 13 | 14 | ) 15 | } -------------------------------------------------------------------------------- /app/components/analytics.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Analytics } from '@vercel/analytics/react'; 3 | 4 | export function AnalyticsWrapper() { 5 | return ; 6 | } -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;200;300;400;500;600;700;800;900&display=swap'); 2 | 3 | :root { 4 | --max-width: 1100px; 5 | --border-radius: 12px; 6 | --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 7 | 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 8 | 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; 9 | 10 | --foreground-rgb: 0, 0, 0; 11 | --background-start-rgb: 214, 219, 220; 12 | --background-end-rgb: 255, 255, 255; 13 | 14 | --primary-glow: conic-gradient( 15 | from 180deg at 50% 50%, 16 | #16abff33 0deg, 17 | #0885ff33 55deg, 18 | #54d6ff33 120deg, 19 | #0071ff33 160deg, 20 | transparent 360deg 21 | ); 22 | --secondary-glow: radial-gradient( 23 | rgba(255, 255, 255, 1), 24 | rgba(255, 255, 255, 0) 25 | ); 26 | 27 | --tile-start-rgb: 239, 245, 249; 28 | --tile-end-rgb: 228, 232, 233; 29 | --tile-border: conic-gradient( 30 | #00000080, 31 | #00000040, 32 | #00000030, 33 | #00000020, 34 | #00000010, 35 | #00000010, 36 | #00000080 37 | ); 38 | 39 | --callout-rgb: 238, 240, 241; 40 | --callout-border-rgb: 172, 175, 176; 41 | --card-rgb: 180, 185, 188; 42 | --card-border-rgb: 131, 134, 135; 43 | } 44 | 45 | 46 | * { 47 | box-sizing: border-box; 48 | padding: 0; 49 | margin: 0; 50 | font-family: 'Poppins', sans-serif; 51 | } 52 | 53 | html, 54 | body { 55 | max-width: 100vw; 56 | overflow-x: hidden; 57 | } 58 | 59 | body { 60 | color: rgb(var(--foreground-rgb)); 61 | } 62 | 63 | a { 64 | color: inherit; 65 | text-decoration: none; 66 | } 67 | 68 | @keyframes spin { 69 | from { 70 | transform:rotate(0deg); 71 | } 72 | to { 73 | transform:rotate(360deg); 74 | } 75 | } 76 | 77 | .spinner { 78 | width: 24px; 79 | margin-right: 10px; 80 | animation-name: spin; 81 | animation-duration: 2000ms; 82 | animation-iteration-count: infinite; 83 | animation-timing-function: linear; 84 | } -------------------------------------------------------------------------------- /app/head.tsx: -------------------------------------------------------------------------------- 1 | export default function Head() { 2 | return ( 3 | <> 4 | Sybil Forms with Gitcoin Passport 5 | 6 | 7 | 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import './globals.css' 3 | import { AnalyticsWrapper } from './components/analytics'; 4 | import '@rainbow-me/rainbowkit/styles.css'; 5 | 6 | import { 7 | getDefaultWallets, 8 | RainbowKitProvider, 9 | } from '@rainbow-me/rainbowkit'; 10 | import { configureChains, createClient, WagmiConfig } from 'wagmi'; 11 | import { mainnet, polygon, optimism, arbitrum } from 'wagmi/chains'; 12 | import { publicProvider } from 'wagmi/providers/public'; 13 | const { chains, provider } = configureChains( 14 | [mainnet, polygon, optimism, arbitrum], 15 | [ 16 | publicProvider() 17 | ] 18 | ); 19 | 20 | const { connectors } = getDefaultWallets({ 21 | appName: 'My RainbowKit App', 22 | chains 23 | }); 24 | 25 | const wagmiClient = createClient({ 26 | autoConnect: true, 27 | connectors, 28 | provider 29 | }) 30 | 31 | export default function RootLayout({ 32 | children, 33 | }: { 34 | children: React.ReactNode 35 | }) { 36 | return ( 37 | 38 | {/* 39 | will contain the components returned by the nearest parent 40 | head.tsx. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head 41 | */} 42 | 43 | 44 | 45 | 46 | {children} 47 | 48 | 49 | 50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/page.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; 6 | padding: 6rem; 7 | min-height: 100vh; 8 | } 9 | 10 | .description { 11 | display: inherit; 12 | justify-content: inherit; 13 | align-items: inherit; 14 | font-size: 0.85rem; 15 | max-width: var(--max-width); 16 | width: 100%; 17 | z-index: 2; 18 | font-family: var(--font-mono); 19 | } 20 | 21 | .code { 22 | font-weight: 700; 23 | font-family: var(--font-mono); 24 | } 25 | 26 | .grid { 27 | display: grid; 28 | grid-template-columns: repeat(3, minmax(33%, auto)); 29 | width: var(--max-width); 30 | max-width: 100%; 31 | } 32 | 33 | .card { 34 | padding: 1rem 1.2rem; 35 | border-radius: var(--border-radius); 36 | background: rgba(var(--card-rgb), 0); 37 | border: 1px solid rgba(var(--card-border-rgb), 0); 38 | transition: background 200ms, border 200ms; 39 | } 40 | 41 | .card span { 42 | display: inline-block; 43 | transition: transform 200ms; 44 | } 45 | 46 | .card h2 { 47 | font-weight: 600; 48 | margin-bottom: 0.7rem; 49 | } 50 | 51 | .card p { 52 | margin: 0; 53 | opacity: 0.6; 54 | font-size: 0.9rem; 55 | line-height: 1.5; 56 | max-width: 34ch; 57 | } 58 | 59 | .center { 60 | display: flex; 61 | justify-content: center; 62 | align-items: center; 63 | position: relative; 64 | padding: 4rem 0; 65 | } 66 | 67 | .center::before { 68 | background: var(--secondary-glow); 69 | border-radius: 50%; 70 | width: 480px; 71 | height: 360px; 72 | margin-left: -400px; 73 | } 74 | 75 | .center::after { 76 | background: var(--primary-glow); 77 | width: 240px; 78 | height: 180px; 79 | z-index: -1; 80 | } 81 | 82 | .center::before, 83 | .center::after { 84 | content: ''; 85 | left: 50%; 86 | position: absolute; 87 | filter: blur(45px); 88 | transform: translateZ(0); 89 | } 90 | 91 | .logo, 92 | .thirteen { 93 | position: relative; 94 | } 95 | 96 | .thirteen { 97 | display: flex; 98 | justify-content: center; 99 | align-items: center; 100 | width: 75px; 101 | height: 75px; 102 | padding: 25px 10px; 103 | margin-left: 16px; 104 | transform: translateZ(0); 105 | border-radius: var(--border-radius); 106 | overflow: hidden; 107 | box-shadow: 0px 2px 8px -1px #0000001a; 108 | } 109 | 110 | .thirteen::before, 111 | .thirteen::after { 112 | content: ''; 113 | position: absolute; 114 | z-index: -1; 115 | } 116 | 117 | /* Conic Gradient Animation */ 118 | .thirteen::before { 119 | animation: 6s rotate linear infinite; 120 | width: 200%; 121 | height: 200%; 122 | background: var(--tile-border); 123 | } 124 | 125 | /* Inner Square */ 126 | .thirteen::after { 127 | inset: 0; 128 | padding: 1px; 129 | border-radius: var(--border-radius); 130 | background: linear-gradient( 131 | to bottom right, 132 | rgba(var(--tile-start-rgb), 1), 133 | rgba(var(--tile-end-rgb), 1) 134 | ); 135 | background-clip: content-box; 136 | } 137 | 138 | /* Enable hover only on non-touch devices */ 139 | @media (hover: hover) and (pointer: fine) { 140 | .card:hover { 141 | background: rgba(var(--card-rgb), 0.1); 142 | border: 1px solid rgba(var(--card-border-rgb), 0.15); 143 | } 144 | 145 | .card:hover span { 146 | transform: translateX(4px); 147 | } 148 | } 149 | 150 | @media (prefers-reduced-motion) { 151 | .thirteen::before { 152 | animation: none; 153 | } 154 | 155 | .card:hover span { 156 | transform: none; 157 | } 158 | } 159 | 160 | /* Mobile and Tablet */ 161 | @media (max-width: 1023px) { 162 | .content { 163 | padding: 4rem; 164 | } 165 | 166 | .grid { 167 | grid-template-columns: 1fr; 168 | margin-bottom: 120px; 169 | max-width: 320px; 170 | text-align: center; 171 | } 172 | 173 | .card { 174 | padding: 1rem 2.5rem; 175 | } 176 | 177 | .card h2 { 178 | margin-bottom: 0.5rem; 179 | } 180 | 181 | .center { 182 | padding: 8rem 0 6rem; 183 | } 184 | 185 | .center::before { 186 | transform: none; 187 | height: 300px; 188 | } 189 | 190 | .description { 191 | font-size: 0.8rem; 192 | } 193 | 194 | .description a { 195 | padding: 1rem; 196 | } 197 | 198 | .description p, 199 | .description div { 200 | display: flex; 201 | justify-content: center; 202 | position: fixed; 203 | width: 100%; 204 | } 205 | 206 | .description p { 207 | align-items: center; 208 | inset: 0 0 auto; 209 | padding: 2rem 1rem 1.4rem; 210 | border-radius: 0; 211 | border: none; 212 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); 213 | background: linear-gradient( 214 | to bottom, 215 | rgba(var(--background-start-rgb), 1), 216 | rgba(var(--callout-rgb), 0.5) 217 | ); 218 | background-clip: padding-box; 219 | backdrop-filter: blur(24px); 220 | } 221 | 222 | .description div { 223 | align-items: flex-end; 224 | pointer-events: none; 225 | inset: auto 0 0; 226 | padding: 2rem; 227 | height: 200px; 228 | background: linear-gradient( 229 | to bottom, 230 | transparent 0%, 231 | rgb(var(--background-end-rgb)) 40% 232 | ); 233 | z-index: 1; 234 | } 235 | } 236 | 237 | @media (prefers-color-scheme: dark) { 238 | .vercelLogo { 239 | filter: invert(1); 240 | } 241 | 242 | .logo, 243 | .thirteen img { 244 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); 245 | } 246 | } 247 | 248 | @keyframes rotate { 249 | from { 250 | transform: rotate(360deg); 251 | } 252 | to { 253 | transform: rotate(0deg); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import dynamic from "next/dynamic" 4 | 5 | const Passport = dynamic( 6 | () => import("./Passport").then((res) => res.default), 7 | { 8 | ssr: false, 9 | } 10 | ) 11 | 12 | export default function Home() { 13 | return ( 14 | 15 | ) 16 | } -------------------------------------------------------------------------------- /app/styles.tsx: -------------------------------------------------------------------------------- 1 | export const styles = { 2 | main: { 3 | width: '900px', 4 | margin: '0 auto', 5 | paddingTop: 90 6 | }, 7 | heading: { 8 | fontSize: 90, 9 | letterSpacing: 'px' 10 | }, 11 | intro: { 12 | fontSize: 18, 13 | color: 'rgba(0, 0, 0, .55)' 14 | }, 15 | 16 | linkStyle: { 17 | color: '#008aff' 18 | }, 19 | buttonContainer: { 20 | marginTop: 20 21 | }, 22 | buttonStyle: { 23 | padding: '10px 30px', 24 | borderRadius: '30px', 25 | backgroundColor: 'black', 26 | color: 'rgba(255, 255, 255, .9)', 27 | outline: 'none', 28 | border: 'none', 29 | cursor: 'pointer', 30 | marginRight: '10px', 31 | borderBottom: '2px solid rgba(0, 0, 0, .2)', 32 | borderRight: '2px solid rgba(0, 0, 0, .2)' 33 | }, 34 | formContainer: { 35 | margin: '20px 0px 10px', 36 | display: 'flex', 37 | flexDirection: 'column' as 'column', 38 | }, 39 | input: { 40 | backgroundColor: 'rgba(0, 0, 0, .1)', 41 | borderRadius: '40px', 42 | padding: '17px 30px', 43 | fontSize: '16px', 44 | border: 'none', 45 | marginBottom: '10px', 46 | outline: 'none' 47 | }, 48 | largeButtonStyle: { 49 | padding: '20px 40px', 50 | borderRadius: '50px', 51 | backgroundColor: 'black', 52 | color: 'rgba(255, 255, 255, .9)', 53 | outline: 'none', 54 | border: 'none', 55 | cursor: 'pointer', 56 | marginRight: '10px', 57 | borderBottom: '2px solid rgba(0, 0, 0, .2)', 58 | borderRight: '2px solid rgba(0, 0, 0, .2)', 59 | fontSize: '18px', 60 | fontWeight: '600' 61 | }, 62 | hiddenMessageContainer: { 63 | marginTop: 15 64 | }, 65 | noScoreMessage: { 66 | marginTop: 20, 67 | marginBottom: 20 68 | }, 69 | stepsContainer: { 70 | marginTop: '20px' 71 | }, 72 | stepsHeader: { 73 | fontSize: '22px', 74 | marginBottom: '9px' 75 | } 76 | } -------------------------------------------------------------------------------- /exm/deploy.js: -------------------------------------------------------------------------------- 1 | /* deploy.js */ 2 | import { ContractType } from '@execution-machine/sdk' 3 | import fs from 'fs' 4 | import { Exm } from '@execution-machine/sdk' 5 | 6 | const state = { 7 | "users": {} 8 | } 9 | 10 | const EXM_API_KEY = process.env.EXM_API_KEY 11 | 12 | const exmInstance = EXM_API_KEY ? new Exm({ token: EXM_API_KEY }) : undefined 13 | const contractSource = fs.readFileSync('handler.js') 14 | const data = await exmInstance.functions.deploy(contractSource, state, ContractType.JS) 15 | 16 | console.log('Function ID: ', data.id) 17 | 18 | /* after the contract is deployed, write the function id to a local file */ 19 | fs.writeFileSync('./functionId.js', `export const functionId = "${data.id}"`) -------------------------------------------------------------------------------- /exm/functionId.js: -------------------------------------------------------------------------------- 1 | export const functionId = "tVMKMvxPZW2RNTg9bKn30m70OxJgIRG8cLeaY4oL_Yk" -------------------------------------------------------------------------------- /exm/handler.js: -------------------------------------------------------------------------------- 1 | export async function handle(state, action) { 2 | const { input } = action 3 | if (input.type === 'setNonce') { 4 | if (!state.users[input.address]) { 5 | state.users[input.address] = {} 6 | state.users[input.address]['address'] = input.address 7 | } 8 | state.users[input.address]['nonce'] = input.nonce 9 | state.users[input.address]['time'] = input.time 10 | } 11 | if (input.type === 'setFormData') { 12 | state.users[input.address]['formData'] = input.formData 13 | state.users[input.address]['verified'] = true 14 | state.users[input.address]['score'] = input.score 15 | } 16 | return { state } 17 | } -------------------------------------------------------------------------------- /exm/read.js: -------------------------------------------------------------------------------- 1 | import { Exm } from '@execution-machine/sdk' 2 | import { functionId } from './functionId.js' 3 | 4 | const EXM_API_KEY = process.env.EXM_API_KEY 5 | const exmInstance = EXM_API_KEY ? new Exm({ token: EXM_API_KEY }) : undefined 6 | 7 | const data = await exmInstance.functions.read(functionId) 8 | console.log("data: ", JSON.stringify(data)) -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | webpack: (config, options) => { 7 | config.experiments.asyncWebAssembly = true 8 | return config 9 | } 10 | } 11 | 12 | export default nextConfig 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sybil-forms-with-nextjs-and-passport", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@execution-machine/sdk": "^0.1.6", 14 | "@gitcoinco/passport-sdk-reader": "^0.1.4", 15 | "@gitcoinco/passport-sdk-scorer": "^0.2.0", 16 | "@gitcoinco/passport-sdk-verifier": "^0.2.2", 17 | "@next/font": "13.1.4", 18 | "@rainbow-me/rainbowkit": "^0.11.0", 19 | "@types/node": "18.11.18", 20 | "@types/react": "18.0.27", 21 | "@types/react-dom": "18.0.10", 22 | "@vercel/analytics": "^0.1.10", 23 | "chai": "^4.3.7", 24 | "date-fns": "^2.29.3", 25 | "eslint": "8.32.0", 26 | "eslint-config-next": "13.1.4", 27 | "ethers": "^5.7.2", 28 | "next": "13.1.4", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "typescript": "4.9.4", 32 | "uuid": "^9.0.0", 33 | "wagmi": "^0.11.7" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pages/api/get-users.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | import { ethers } from 'ethers' 4 | import { functionId } from '../../exm/functionId.js' 5 | import { formatDistance, parseISO } from 'date-fns' 6 | 7 | const FUNCTION_URI = `https://${functionId}.exm.run` 8 | 9 | let admins = [ 10 | "0xB2Ebc9b3a788aFB1E942eD65B59E9E49A1eE500D" 11 | ] 12 | 13 | admins = admins.map(admin => admin.toLocaleLowerCase()) 14 | 15 | type Data = { 16 | status: string, 17 | error?: string, 18 | users?: any[] 19 | } 20 | 21 | function wait() { 22 | return new Promise(resolve => setTimeout(resolve, 1000)); 23 | } 24 | 25 | export default async function handler( 26 | req: NextApiRequest, 27 | res: NextApiResponse 28 | ) { 29 | let body = JSON.parse(req.body) 30 | const address = body.address.toLowerCase() 31 | const signature = body.signature 32 | 33 | async function verify(signature, retries) { 34 | if (retries < 1) { 35 | res.status(200).json({ 36 | status: 'error', 37 | error: 'Nonce mismatch.' 38 | }) 39 | } 40 | const response = await fetch(FUNCTION_URI) 41 | const json = await response.json() 42 | const { nonce, time } = json['users'][address] 43 | const decodedAddress = ethers.utils.verifyMessage(nonce, signature) 44 | 45 | if (decodedAddress.toLowerCase() === address) { 46 | console.log('success...') 47 | const distance = formatDistance(new Date(), parseISO(time)) 48 | if (distance == 'less than a minute') { 49 | return true 50 | } else { 51 | console.log('distance retry...') 52 | await wait() 53 | return await verify(signature, retries - 1) 54 | } 55 | } else { 56 | console.log('signature retry...') 57 | await wait() 58 | return await verify(signature, retries - 1) 59 | } 60 | } 61 | 62 | const verified = await verify(signature, 10) 63 | if(verified) { 64 | if (!admins.includes(address.toLowerCase())) { 65 | console.log('not an admin...') 66 | res.status(200).json({ 67 | status: 'error', 68 | error: 'Not an admin.' 69 | }) 70 | } else { 71 | const response = await fetch(FUNCTION_URI) 72 | const json = await response.json() 73 | const users = Object.values(json.users) 74 | 75 | res.status(200).json({ 76 | status: 'success', 77 | users 78 | }) 79 | } 80 | } else { 81 | res.status(200).json({ 82 | status: 'error', 83 | error: 'nonce mismatch' 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /pages/api/post.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | import { ethers } from 'ethers' 4 | import { functionId } from '../../exm/functionId.js' 5 | import { formatDistance, parseISO } from 'date-fns' 6 | 7 | const API_KEY = process.env.NEXT_PUBLIC_GC_API_KEY 8 | const COMMUNITY_ID = process.env.NEXT_PUBLIC_GC_COMMUNITY_ID 9 | const FUNCTION_URI = `https://${functionId}.exm.run` 10 | const THRESHOLD:number = Number(process.env.NEXT_PUBLIC_THRESHOLD) 11 | 12 | const headers = API_KEY ? ({ 13 | 'Content-Type': 'application/json', 14 | 'X-API-Key': API_KEY 15 | }) : undefined 16 | 17 | type Data = { 18 | status: string, 19 | error?: string 20 | } 21 | 22 | function wait() { 23 | return new Promise(resolve => setTimeout(resolve, 1000)); 24 | } 25 | 26 | export default async function handler( 27 | req: NextApiRequest, 28 | res: NextApiResponse 29 | ) { 30 | 31 | async function verify(address, retries, signature) { 32 | if (retries < 1) { 33 | res.status(200).json({ 34 | status: 'error', 35 | error: 'nonce mismatch' 36 | }) 37 | } 38 | try { 39 | let exmdata = await fetch(FUNCTION_URI) 40 | exmdata = await exmdata.json() 41 | let user = exmdata['users'][address] 42 | if (user) { 43 | const decodedAddress = ethers.utils.verifyMessage(user.nonce, signature) 44 | if (address.toLowerCase() === decodedAddress.toLowerCase()) { 45 | return user 46 | } else { 47 | await wait() 48 | return await verify(address, retries - 1, signature) 49 | } 50 | } else { 51 | await wait() 52 | return await verify(address, retries - 1, signature) 53 | } 54 | } catch (err) { 55 | console.log("error: ", err) 56 | await wait() 57 | return await verify(address, retries - 1, signature) 58 | } 59 | } 60 | 61 | async function checkTime(time, retries) { 62 | if (retries < 1) { 63 | res.status(200).json({ 64 | status: 'error', 65 | error: 'nonce mismatch' 66 | }) 67 | } 68 | const distance = formatDistance(new Date(), parseISO(time)) 69 | if (distance !== 'less than a minute') { 70 | await wait() 71 | return await checkTime(time, retries - 1) 72 | } 73 | } 74 | 75 | try { 76 | let address, signature, formData 77 | let body = JSON.parse(req.body) 78 | address = body.address.toLowerCase() 79 | signature = body.signature 80 | formData = body.formData 81 | const GET_PASSPORT_SCORE_URI = `https://api.scorer.gitcoin.co/registry/score/${COMMUNITY_ID}/${address}` 82 | const { nonce, time } = await verify(address, 10, signature) 83 | await checkTime(time, 5) 84 | 85 | const decodedAddress = ethers.utils.verifyMessage(nonce, signature) 86 | if(address.toLowerCase() === decodedAddress.toLowerCase()) { 87 | const response = await fetch(GET_PASSPORT_SCORE_URI, { 88 | headers 89 | }) 90 | const passportData = await response.json() 91 | if (parseInt(passportData.score) >= THRESHOLD) { 92 | const input = { 93 | type: 'setFormData', 94 | address, 95 | formData, 96 | score: passportData.score 97 | } 98 | let response = await fetch(FUNCTION_URI, { 99 | method: 'POST', 100 | headers: { 101 | 'Content-Type': 'application/json' 102 | }, 103 | body: JSON.stringify({ 104 | ...input 105 | }) 106 | }) 107 | response = await response.json() 108 | res.status(200).json({ status: 'success' }) 109 | } else { 110 | console.log('score not met') 111 | res.status(200).json({ status: 'failure', error: 'score did not meet threshold' }) 112 | } 113 | } else { 114 | console.log('nonce mismatch') 115 | res.status(200).json({ 116 | status: 'error', 117 | error: 'nonce mismatch' 118 | }) 119 | } 120 | } catch (err) { 121 | console.log('error from verify: ', err) 122 | res.status(500) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /pages/api/set-nonce.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | import { v4 as uuid } from 'uuid' 4 | import { functionId } from '../../exm/functionId.js' 5 | 6 | const FUNCTION_URI = `https://${functionId}.exm.run` 7 | 8 | const headers = { 9 | 'Content-Type': 'application/json' 10 | } 11 | 12 | type Data = { 13 | status: string, 14 | nonce: string 15 | } 16 | 17 | export default async function handler( 18 | req: NextApiRequest, 19 | res: NextApiResponse 20 | ) { 21 | let address 22 | if (req.body) { 23 | let body = JSON.parse(req.body) 24 | address = body.address.toLowerCase() 25 | } 26 | const nonce = uuid() 27 | const input = { 28 | type: 'setNonce', 29 | nonce, 30 | address, 31 | time: new Date() 32 | } 33 | try { 34 | await fetch(FUNCTION_URI, { 35 | method: 'POST', 36 | headers, 37 | body: JSON.stringify({ 38 | ...input 39 | }) 40 | }) 41 | 42 | res.status(200).json({ 43 | status: 'success', 44 | nonce 45 | }) 46 | } catch (err) { 47 | console.log('err: ', err) 48 | res.status(500) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dabit3/sybil-form/491155f7a8a7e7198f96a3ac583ce809ad84b594/public/favicon.ico -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "noImplicitAny": false, 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ], 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": ["./*"] 26 | } 27 | }, 28 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 29 | "exclude": ["node_modules"] 30 | } 31 | --------------------------------------------------------------------------------