├── .env.sample ├── .eslintrc.json ├── .gitignore ├── .gitmodules ├── LICENSE.md ├── README.md ├── app ├── api │ ├── aiSummary │ │ └── route.ts │ ├── frame │ │ └── home │ │ │ └── route.ts │ ├── getTrendingEIPs │ │ └── route.ts │ ├── logPageVisit │ │ └── route.ts │ ├── og │ │ └── route.tsx │ └── webhook │ │ └── route.ts ├── caip │ └── [eipOrNo] │ │ ├── layout.tsx │ │ └── page.tsx ├── eip │ └── [eipOrNo] │ │ ├── layout.tsx │ │ └── page.tsx ├── fonts.ts ├── graph │ ├── layout.tsx │ └── page.tsx ├── icon.png ├── layout.tsx ├── lib │ └── neynar.ts ├── page.tsx ├── providers.tsx ├── rip │ └── [eipOrNo] │ │ ├── layout.tsx │ │ └── page.tsx └── shared │ ├── layout.tsx │ └── page.tsx ├── assets └── Poppins-Bold.ttf ├── components ├── Analytics.tsx ├── CodeBlock.tsx ├── CopyToClipboard.tsx ├── EIPGraph.tsx ├── EIPGraphSection.tsx ├── EIPGraphWrapper.tsx ├── EIPOfTheDay.tsx ├── Footer.tsx ├── Layout.tsx ├── Markdown.tsx ├── Navbar.tsx ├── NotificationBar.tsx ├── PectraEIPs.tsx ├── ScrollToTopButton.tsx ├── Searchbox.tsx └── TrendingEIPs.tsx ├── data ├── eip-graph-data.json ├── eipGraphData.ts ├── schemas.ts ├── valid-caips.json ├── valid-eips.json ├── valid-rips.json ├── validCAIPs.ts ├── validEIPs.ts └── validRIPs.ts ├── funding.json ├── hooks └── useTopLoaderRouter.tsx ├── middleware.ts ├── models ├── aiSummary.ts └── pageVisit.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── public ├── eth.png └── og │ ├── base.png │ ├── graph.png │ └── index.png ├── scripts ├── create-webhook.ts ├── fetchValidEIPs.ts ├── genEIPDependencyGraph.ts ├── getWIPEIPsFromPRs.ts └── test-webhook.ts ├── style └── theme.ts ├── styles └── globals.css ├── temp └── eip-7830.md ├── tsconfig.json ├── types.ts └── utils └── index.ts /.env.sample: -------------------------------------------------------------------------------- 1 | # .env.local 2 | GITHUB_TOKEN= 3 | MONGODB_URL= 4 | NEXT_PUBLIC_DEVELOPMENT=true 5 | HOST=http://localhost:3000 6 | OPENAI_API_KEY= 7 | OPENAI_ORG_ID= 8 | 9 | NEYNAR_API_KEY= 10 | WEBHOOK_URL=https://eip.tools/api/webhook 11 | NEYNAR_SIGNER_UUID= 12 | NEYNAR_WEBHOOK_SECRET= 13 | 14 | NEXT_PUBLIC_GITCOIN_GRANTS_LINK= 15 | NEXT_PUBLIC_GITCOIN_GRANTS_ACTIVE="false" 16 | NEXT_PUBLIC_VERCEL_URL=localhost:3000 -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "submodules/ERCs"] 2 | path = submodules/ERCs 3 | url = https://github.com/ethereum/ERCs.git 4 | [submodule "submodules/EIPs"] 5 | path = submodules/EIPs 6 | url = https://github.com/ethereum/EIPs.git 7 | [submodule "submodules/RIPs"] 8 | path = submodules/RIPs 9 | url = https://github.com/ethereum/RIPs.git 10 | [submodule "submodules/CAIPs"] 11 | path = submodules/CAIPs 12 | url = https://github.com/ChainAgnostic/CAIPs 13 | branch = main 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Apoorv Lathey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eip.tools 2 | -------------------------------------------------------------------------------- /app/api/aiSummary/route.ts: -------------------------------------------------------------------------------- 1 | // add this to prevent the build command from static generating this page 2 | export const dynamic = "force-dynamic"; 3 | 4 | import mongoose from "mongoose"; 5 | import OpenAI from "openai"; 6 | 7 | import { AISummary } from "@/models/aiSummary"; 8 | import { AISummaryRequest, AISummaryRequestSchema } from "@/data/schemas"; 9 | import { validEIPs } from "@/data/validEIPs"; 10 | import { validRIPs } from "@/data/validRIPs"; 11 | import { validCAIPs } from "@/data/validCAIPs"; 12 | import { EIPStatus } from "@/utils"; 13 | 14 | const openai = new OpenAI({ 15 | organization: process.env.OPENAI_ORG_ID, 16 | project: null, // Default Project 17 | apiKey: process.env.OPENAI_API_KEY, 18 | }); 19 | 20 | export const POST = async (request: Request) => { 21 | // validate request body 22 | let body: AISummaryRequest; 23 | try { 24 | const requestBody = await request.json(); 25 | body = AISummaryRequestSchema.parse(requestBody); 26 | } catch { 27 | return new Response("Invalid request body", { status: 400 }); 28 | } 29 | 30 | const eipNo = body.eipNo; 31 | const type = body.type ?? "EIP"; 32 | 33 | await mongoose.connect(process.env.MONGODB_URL!); 34 | 35 | // query "summaries" mongodb for this eipNo 36 | const summaryRes = await AISummary.findOne({ eipNo }); 37 | 38 | // If summary exists, return 39 | if (summaryRes) { 40 | return new Response(JSON.stringify(summaryRes.summary), { 41 | status: 200, 42 | }); 43 | } 44 | 45 | // If not, then send EIP markdown to chatgpt api 46 | const { status, markdownPath } = 47 | type === "RIP" 48 | ? validRIPs[eipNo] 49 | : type === "CAIP" 50 | ? validCAIPs[eipNo] 51 | : validEIPs[eipNo]; 52 | const markdown = await fetch(markdownPath).then((res) => res.text()); 53 | 54 | try { 55 | const stream = await openai.chat.completions.create({ 56 | model: "gpt-4o", 57 | messages: [ 58 | { 59 | role: "user", 60 | content: `Summarize the following EIP/ERC no: ${eipNo} into easy to understand & just in few lines. 61 | Don't start with any other extra stuff, only output concise 4-6 lines. I need to directly display this output on a website. 62 | Here's the markdown to summarize: 63 | ${markdown}`, 64 | }, 65 | ], 66 | stream: true, 67 | }); 68 | 69 | let response = ""; 70 | for await (const chunk of stream) { 71 | const content = chunk.choices[0]?.delta?.content || ""; 72 | response += content; 73 | } 74 | 75 | // save the response in mongodb 76 | await AISummary.create({ 77 | eipNo, 78 | summary: response, 79 | eipStatus: status, 80 | timestamp: new Date(), 81 | }); 82 | 83 | // return string summary 84 | return new Response(JSON.stringify(response), { 85 | status: 200, 86 | }); 87 | } catch (e) { 88 | // send error 89 | return new Response(JSON.stringify(e), { status: 500 }); 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /app/api/frame/home/route.ts: -------------------------------------------------------------------------------- 1 | // add this to prevent the build command from static generating this page 2 | export const dynamic = "force-dynamic"; 3 | 4 | import { validEIPs } from "@/data/validEIPs"; 5 | import { NextRequest, NextResponse } from "next/server"; 6 | 7 | export async function POST(req: NextRequest) { 8 | const { untrustedData } = await req.json(); 9 | 10 | if (untrustedData.inputText && untrustedData.inputText.length > 0) { 11 | try { 12 | const eipNo = parseInt(untrustedData.inputText as string); 13 | const validEIP = validEIPs[eipNo]; 14 | 15 | const imageUrl = `${process.env["HOST"]}/api/og?eipNo=${eipNo}`; 16 | const postUrl = `${process.env["HOST"]}/api/frame/home`; 17 | 18 | return new NextResponse( 19 | ` 20 | 21 |
22 |59 | `, 60 | { 61 | status: 200, 62 | headers: { 63 | "Content-Type": "text/html", 64 | }, 65 | } 66 | ); 67 | } catch {} 68 | } 69 | 70 | // return back the same frame if no / invalid input received 71 | const imageUrl = `${process.env["HOST"]}/og/index.png?date=${Date.now()}`; 72 | const postUrl = `${process.env["HOST"]}/api/frame/home`; 73 | 74 | return new NextResponse( 75 | ` 76 | 77 |
78 |
79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 |
97 | `, 98 | { 99 | status: 200, 100 | headers: { 101 | "Content-Type": "text/html", 102 | }, 103 | } 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /app/api/getTrendingEIPs/route.ts: -------------------------------------------------------------------------------- 1 | // add this to prevent the build command from static generating this page 2 | export const dynamic = "force-dynamic"; 3 | 4 | import { NextRequest } from "next/server"; 5 | import mongoose from "mongoose"; 6 | import { subDays } from "date-fns"; 7 | import { PageVisit } from "@/models/pageVisit"; 8 | 9 | export const GET = async (req: NextRequest) => { 10 | await mongoose.connect(process.env.MONGODB_URL!); 11 | 12 | const sevenDaysAgo = subDays(new Date(), 7); 13 | const trendingCount = 5; 14 | 15 | const topPages = await PageVisit.aggregate([ 16 | { 17 | $match: { 18 | timestamp: { $gte: sevenDaysAgo }, 19 | }, 20 | }, 21 | { 22 | $group: { 23 | _id: "$eipNo", 24 | count: { $sum: 1 }, 25 | type: { $first: "$type" }, // Assuming 'type' is a field in the same collection 26 | }, 27 | }, 28 | { 29 | $sort: { count: -1 }, 30 | }, 31 | { 32 | $limit: trendingCount, 33 | }, 34 | ]); 35 | 36 | return new Response(JSON.stringify(topPages), { 37 | status: 200, 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /app/api/logPageVisit/route.ts: -------------------------------------------------------------------------------- 1 | // add this to prevent the build command from static generating this page 2 | export const dynamic = "force-dynamic"; 3 | 4 | import mongoose from "mongoose"; 5 | import { PageVisit } from "@/models/pageVisit"; 6 | import { LogPageVisitRequest, logPageVisitRequestSchema } from "@/data/schemas"; 7 | 8 | export const POST = async (request: Request) => { 9 | // validate request body 10 | let body: LogPageVisitRequest; 11 | try { 12 | const requestBody = await request.json(); 13 | body = logPageVisitRequestSchema.parse(requestBody); 14 | } catch { 15 | return new Response("Invalid request body", { status: 400 }); 16 | } 17 | 18 | const eipNo = body.eipNo; 19 | const type = body.type; 20 | 21 | await mongoose.connect(process.env.MONGODB_URL!); 22 | 23 | const pageVisit = new PageVisit({ 24 | eipNo, 25 | type, 26 | }); 27 | await pageVisit.save(); 28 | 29 | return new Response("Page visit logged", { status: 200 }); 30 | }; 31 | -------------------------------------------------------------------------------- /app/api/og/route.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "next/og"; 2 | import { validEIPs } from "@/data/validEIPs"; 3 | import { validRIPs } from "@/data/validRIPs"; 4 | import { validCAIPs } from "@/data/validCAIPs"; 5 | import { EIPStatus } from "@/utils"; 6 | 7 | export const runtime = "edge"; 8 | 9 | export async function GET(req: Request) { 10 | try { 11 | const { searchParams } = new URL(req.url); 12 | 13 | const eipNo = parseInt(searchParams.get("eipNo")!); 14 | const type = searchParams.get("type") ?? "EIP"; 15 | const eipData = 16 | type === "RIP" 17 | ? validRIPs[eipNo] 18 | : type === "CAIP" 19 | ? validCAIPs[eipNo] 20 | : validEIPs[eipNo]; 21 | const statusInfo = EIPStatus[eipData.status ?? "Draft"]; 22 | 23 | const fontData = await fetch( 24 | new URL("../../../assets/Poppins-Bold.ttf", import.meta.url) 25 | ).then((res) => res.arrayBuffer()); 26 | 27 | const imgArrayBuffer = await fetch( 28 | new URL("../../../public/og/base.png", import.meta.url) 29 | ).then((res) => res.arrayBuffer()); 30 | const buffer = Buffer.from(imgArrayBuffer); 31 | const imgUrl = `data:image/png;base64,${buffer.toString("base64")}`; 32 | 33 | return new ImageResponse( 34 | ( 35 |
104 | ),
105 | {
106 | width: 2144,
107 | height: 1122,
108 | fonts: [
109 | {
110 | name: "Poppins",
111 | data: fontData,
112 | style: "normal",
113 | },
114 | ],
115 | }
116 | );
117 | } catch (e: any) {
118 | return new Response(e.message, { status: 500 });
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/app/api/webhook/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { validEIPsArray } from "@/data/validEIPs";
3 | import neynarClient from "@/app/lib/neynar";
4 | import crypto from "crypto";
5 |
6 | // Define allowed methods
7 | export const dynamic = "force-dynamic";
8 |
9 | // EIP numbers to ignore
10 | const IGNORED_EIPS = [
11 | "1",
12 | "2",
13 | "3",
14 | "4",
15 | "5",
16 | "6",
17 | "7",
18 | "8",
19 | "9",
20 | "20",
21 | "155",
22 | "721",
23 | "1155",
24 | "2025",
25 | ];
26 |
27 | interface WebhookData {
28 | type: string;
29 | data: {
30 | text: string;
31 | hash: string;
32 | };
33 | }
34 |
35 | // This is required to handle OPTIONS requests from CORS preflight
36 | export async function OPTIONS() {
37 | return new NextResponse(null, {
38 | status: 200,
39 | headers: {
40 | Allow: "POST",
41 | },
42 | });
43 | }
44 |
45 | export async function POST(req: Request) {
46 | try {
47 | // Get the signature from header
48 | const signature = req.headers.get("x-neynar-signature");
49 | if (!signature) {
50 | return NextResponse.json(
51 | { error: "Missing signature header" },
52 | { status: 401 }
53 | );
54 | }
55 |
56 | // Get the raw request body as a string
57 | const rawBody = await req.text();
58 |
59 | // Create signature using shared secret
60 | const webhookSecret = process.env.NEYNAR_WEBHOOK_SECRET;
61 | if (!webhookSecret) {
62 | throw new Error("NEYNAR_WEBHOOK_SECRET not set in environment variables");
63 | }
64 |
65 | // Convert webhook secret to Uint8Array
66 | const encoder = new TextEncoder();
67 | const keyData = encoder.encode(webhookSecret);
68 | const messageData = encoder.encode(rawBody);
69 |
70 | // Create HMAC using Web Crypto API
71 | const key = await crypto.subtle.importKey(
72 | "raw",
73 | keyData,
74 | { name: "HMAC", hash: "SHA-512" },
75 | false,
76 | ["sign"]
77 | );
78 |
79 | const signature_array = await crypto.subtle.sign("HMAC", key, messageData);
80 |
81 | // Convert to hex string
82 | const computedSignature = Array.from(new Uint8Array(signature_array))
83 | .map((b) => b.toString(16).padStart(2, "0"))
84 | .join("");
85 |
86 | // Compare signatures
87 | if (signature !== computedSignature) {
88 | return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
89 | }
90 |
91 | // Parse the body after verification
92 | const body = JSON.parse(rawBody) as WebhookData;
93 |
94 | if (body.type === "cast.created") {
95 | const text = body.data.text;
96 | const originalCastHash = body.data.hash;
97 |
98 | // Match patterns for numbers with optional EIP/ERC prefix
99 | const pattern = /(?:eip[-\s]?(\d+)|erc[-\s]?(\d+)|(? 0) {
118 | if (!process.env.NEYNAR_SIGNER_UUID) {
119 | throw new Error(
120 | "Make sure you set NEYNAR_SIGNER_UUID in your .env file"
121 | );
122 | }
123 |
124 | // Create the reply text based on number of URLs
125 | let replyText = "Explore the EIPs / ERCs mentioned in this cast:";
126 | if (urls.length >= 2) {
127 | const additionalUrls = urls.slice(1);
128 | replyText += `\n\n${additionalUrls.join("\n")}`;
129 | }
130 |
131 | // Post the reply using Neynar client
132 | const reply = await neynarClient.publishCast({
133 | signerUuid: process.env.NEYNAR_SIGNER_UUID,
134 | text: replyText,
135 | embeds: [
136 | {
137 | url: urls[0],
138 | },
139 | ],
140 | parent: originalCastHash,
141 | });
142 |
143 | console.log("Posted reply:", reply.cast);
144 | }
145 | }
146 |
147 | return NextResponse.json({ success: true });
148 | } catch (error) {
149 | console.error("Webhook error:", error);
150 | return NextResponse.json(
151 | { error: "Internal server error" },
152 | { status: 500 }
153 | );
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/app/caip/[eipOrNo]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from "@/components/Layout";
2 | import {
3 | convertMetadataToJson,
4 | extractEipNumber,
5 | extractMetadata,
6 | getMetadata,
7 | } from "@/utils";
8 | import { validCAIPs } from "@/data/validCAIPs";
9 |
10 | export async function generateMetadata({
11 | params: { eipOrNo },
12 | }: {
13 | params: { eipOrNo: string };
14 | }) {
15 | const eipNo = extractEipNumber(eipOrNo, "caip");
16 | const validEIPData = validCAIPs[parseInt(eipNo)];
17 |
18 | if (!validEIPData) {
19 | return;
20 | }
21 |
22 | const eipMarkdownRes = await fetch(validEIPData.markdownPath).then(
23 | (response) => response.text()
24 | );
25 | const { metadata } = extractMetadata(eipMarkdownRes);
26 | const metadataJson = convertMetadataToJson(metadata);
27 |
28 | const imageUrl = `${process.env["HOST"]}/api/og?eipNo=${eipNo}&type=CAIP`;
29 | const postUrl = `${process.env["HOST"]}/api/frame/home`;
30 |
31 | const generated = getMetadata({
32 | title: `CAIP-${eipNo}: ${validEIPData.title} | EIP.tools`,
33 | description: metadataJson.description,
34 | images: imageUrl,
35 | });
36 |
37 | return {
38 | ...generated,
39 | other: {
40 | "fc:frame": "vNext",
41 | "fc:frame:image": imageUrl,
42 | "fc:frame:post_url": postUrl,
43 | "fc:frame:input:text": "Enter EIP/ERC No",
44 | "fc:frame:button:1": "Search 🔎",
45 | "fc:frame:button:2": `📙 ${validEIPData.isERC ? "ERC" : "EIP"}-${eipNo}`,
46 | "fc:frame:button:2:action": "link",
47 | "fc:frame:button:2:target": `${process.env["HOST"]}/eip/${eipNo}`,
48 | "of:version": "vNext",
49 | "of:accepts:anonymous": "true",
50 | "of:image": imageUrl,
51 | "of:post_url": postUrl,
52 | "of:input:text": "Enter EIP/ERC No",
53 | "of:button:1": "Search 🔎",
54 | "of:button:2": `📙 ${validEIPData.isERC ? "ERC" : "EIP"}-${eipNo}`,
55 | "of:button:2:action": "link",
56 | "of:button:2:target": `${process.env["HOST"]}/eip/${eipNo}`,
57 | },
58 | };
59 | }
60 |
61 | export default function EIPLayout({ children }: { children: React.ReactNode }) {
62 | return
355 | {metadataJson.author && (
356 |
422 |
357 |
381 | )}
382 | {metadataJson.created && (
383 | Authors
358 |
359 |
380 |
384 |
387 | )}
388 | {metadataJson["discussions-to"] && (
389 | Created
385 | {metadataJson.created}
386 |
390 |
401 | )}
402 | {metadataJson.requires && metadataJson.requires.length > 0 && (
403 | Discussion Link
391 |
392 |
397 | {metadataJson["discussions-to"]}
398 |
399 |
400 |
404 |
420 | )}
421 | Requires
405 |
406 |
419 |
363 | {metadataJson.author && (
364 |
457 |
365 |
389 | )}
390 | {metadataJson.created && (
391 | Authors
366 |
367 |
388 |
392 |
395 | )}
396 | {metadataJson["discussions-to"] && (
397 | Created
393 | {metadataJson.created}
394 |
398 |
409 | )}
410 | {metadataJson.requires && metadataJson.requires.length > 0 && (
411 | Discussion Link
399 |
400 |
405 | {metadataJson["discussions-to"]}
406 |
407 |
408 |
412 |
428 | )}
429 | {markdownFileURL && (
430 | Requires
413 |
414 |
427 |
431 |
455 | )}
456 |
432 |
441 |
442 |
454 |