({
+ logEventWithContext: function () {
+ return undefined;
+ },
+ fullContext: '',
+});
+
+export function useAnalytics() {
+ const context = useContext(AnalyticsContext);
+ if (context === undefined) {
+ throw new Error('useAnalytics must be used within a AnalyticsProvider');
+ }
+ return context;
+}
+
+type AnalyticsProviderProps = {
+ children?: ReactNode;
+ context: string; // TODO: This could be an enum in CCAEventData
+};
+
+export default function AnalyticsProvider({ children, context }: AnalyticsProviderProps) {
+ const { fullContext: previousContext } = useAnalytics();
+
+ const fullContext = [previousContext, context].filter((c) => !!c).join('_');
+ const logEventWithContext = useCallback(
+ (eventName: string, action: ActionType, eventData?: CCAEventData) => {
+ const sanitizedEventName = eventName.toLocaleLowerCase();
+ logEvent(
+ sanitizedEventName, // TODO: Do we want context here?
+ {
+ action: action,
+ context: fullContext,
+ page_path: window.location.pathname,
+ ...eventData,
+ },
+ AnalyticsEventImportance.high,
+ );
+ },
+ [fullContext],
+ );
+
+ const values = useMemo(() => {
+ return { logEventWithContext, context, fullContext };
+ }, [context, fullContext, logEventWithContext]);
+
+ return {children};
+}
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index 4f11a03..fd36f94 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,5 +1,6 @@
///
///
+///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/apps/web/next.config.js b/apps/web/next.config.js
index bed6b11..7625c93 100644
--- a/apps/web/next.config.js
+++ b/apps/web/next.config.js
@@ -76,6 +76,8 @@ const contentSecurityPolicy = {
],
'connect-src': [
"'self'",
+ 'https://blob.vercel-storage.com', // Vercel File storage
+ 'https://zku9gdedgba48lmr.public.blob.vercel-storage.com', // Vercel File storage
walletconnectDomains,
sprigDomains,
greenhouseDomains,
@@ -84,9 +86,14 @@ const contentSecurityPolicy = {
'wss://www.walletlink.org/rpc', // coinbase wallet connection
'https://analytics-service-dev.cbhq.net',
'mainnet.base.org',
+ 'sepolia.base.org',
'https://cloudflare-eth.com',
'https://i.seadn.io/', // ens avatars
'https://api.opensea.io', // enables getting ENS avatars
+ 'https://ipfs.io', // ipfs ens avatar resolution
+ 'wss://www.walletlink.org',
+ 'https://base.easscan.org/graphql',
+ 'https://api.guild.xyz/',
isLocalDevelopment ? 'ws://localhost:3000/' : '',
isLocalDevelopment ? 'http://localhost:3000/' : '',
'https://flag.lab.amplitude.com/sdk/v2/flags',
@@ -96,9 +103,13 @@ const contentSecurityPolicy = {
'form-action': ["'self'", baseXYZDomains],
'img-src': [
"'self'",
+ 'blob:',
+ 'https://blob.vercel-storage.com', // Vercel File storage
+ 'https://zku9gdedgba48lmr.public.blob.vercel-storage.com', // Vercel File storage
'data:',
'https://*.walletconnect.com/', // WalletConnect
'https://i.seadn.io/', // ens avatars
+ 'https://ipfs.io', // ipfs ens avatar resolution
],
};
@@ -154,6 +165,24 @@ module.exports = extendBaseConfig(
protocol: 'https',
hostname: 'i.seadn.io',
},
+ {
+ protocol: 'https',
+ hostname: 'ipfs.io',
+ },
+ {
+ protocol: 'https',
+ hostname: 'cf-ipfs.com',
+ },
+ {
+ protocol: 'https',
+ hostname: 'blob.vercel-storage.com',
+ port: '',
+ },
+ {
+ protocol: 'https',
+ hostname: 'zku9gdedgba48lmr.public.blob.vercel-storage.com',
+ port: '',
+ },
],
},
async headers() {
diff --git a/apps/web/package.json b/apps/web/package.json
index fd13eb8..2e16e90 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -13,17 +13,29 @@
"dependencies": {
"@coinbase/cookie-banner": "^1.0.3",
"@coinbase/cookie-manager": "^1.1.1",
- "@coinbase/onchainkit": "^0.6.0",
- "@heroicons/react": "^2.0.18",
+ "@coinbase/onchainkit": "^0.26.3",
+ "@guildxyz/sdk": "2.6.0",
+ "@headlessui/react": "^1.7.19",
+ "@heroicons/react": "^2.1.3",
+ "@next/font": "^13.1.5",
+ "@radix-ui/react-tooltip": "^1.1.2",
"@rainbow-me/rainbowkit": "^2.1.3",
- "@tanstack/react-query": "^5.29.2",
+ "@tanstack/react-query": "^5.51.11",
+ "@types/jsonwebtoken": "^9.0.6",
+ "@vercel/blob": "^0.23.4",
"@vercel/kv": "^1.0.1",
+ "@vercel/og": "^0.6.2",
"@vercel/postgres-kysely": "^0.8.0",
"base-ui": "0.1.1",
+ "classnames": "^2.5.1",
"ethers": "5.7.2",
"framer-motion": "^8.5.5",
+ "jose": "^5.4.1",
+ "jsonwebtoken": "^9.0.2",
"kysely": "^0.27.3",
- "next": "^13.2.0",
+ "next": "^13.5.6",
+ "node-fetch": "^3.3.0",
+ "permissionless": "^0.1.41",
"pg": "^8.12.0",
"react": "^18.2.0",
"react-blockies": "^1.4.1",
@@ -31,21 +43,24 @@
"react-dom": "^18.2.0",
"react-intersection-observer": "^9.10.2",
"react-intl": "^6.2.1",
+ "satori": "^0.10.14",
"sharp": "^0.33.4",
+ "twemoji": "^14.0.2",
"usehooks-ts": "^3.1.0",
"uuidv4": "^6.2.13",
"viem": "2.x",
- "wagmi": "^2.5.20"
+ "wagmi": "^2.11.3"
},
"devDependencies": {
"@types/node": "18.11.18",
"@types/pg": "^8.11.6",
+ "@types/react": "^18",
"@types/react-copy-to-clipboard": "^5.0.7",
+ "@types/react-dom": "^18",
"autoprefixer": "^10.4.13",
"csv-parser": "^3.0.0",
"dotenv": "^16.0.3",
"eslint-config-next": "^13.1.6",
- "node-fetch": "^3.3.0",
"postcss": "^8.4.21",
"prettier-plugin-tailwindcss": "^0.2.5",
"tailwindcss": "^3.2.4",
diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx
index d808bc9..37a60c9 100644
--- a/apps/web/pages/_app.tsx
+++ b/apps/web/pages/_app.tsx
@@ -1,3 +1,4 @@
+import '@coinbase/onchainkit/styles.css';
import './global.css';
import {
@@ -6,6 +7,8 @@ import {
TrackingCategory,
TrackingPreference,
} from '@coinbase/cookie-manager';
+import { OnchainKitProvider } from '@coinbase/onchainkit';
+import { Provider as TooltipProvider } from '@radix-ui/react-tooltip';
import { connectorsForWallets, RainbowKitProvider } from '@rainbow-me/rainbowkit';
import '@rainbow-me/rainbowkit/styles.css';
import {
@@ -16,14 +19,16 @@ import {
walletConnectWallet,
} from '@rainbow-me/rainbowkit/wallets';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { UserAvatar } from 'apps/web/src/components/ConnectWalletButton/UserAvatar';
import useSprig from 'base-ui/hooks/useSprig';
import { MotionConfig } from 'framer-motion';
+import { NextPage } from 'next';
import { AppProps } from 'next/app';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { ReactElement, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { createConfig, http, WagmiProvider } from 'wagmi';
import { base, baseSepolia, mainnet, sepolia } from 'wagmi/chains';
import ClientAnalyticsScript from '../src/components/ClientAnalyticsScript/ClientAnalyticsScript';
-import { Layout } from '../src/components/Layout/Layout';
+import { Layout, NavigationType } from '../src/components/Layout/Layout';
import { cookieManagerConfig } from '../src/utils/cookieManagerConfig';
import ExperimentsProvider from 'base-ui/contexts/Experiments';
@@ -57,11 +62,18 @@ const config = createConfig({
},
ssr: true,
});
-
const queryClient = new QueryClient();
const sprigEnvironmentId = process.env.NEXT_PUBLIC_SPRIG_ENVIRONMENT_ID;
-export default function StaticApp({ Component, pageProps }: AppProps) {
+export type NextPageWithLayout = NextPage
& {
+ getLayout?: (page: ReactElement) => ReactNode;
+};
+
+type AppPropsWithLayout = AppProps & {
+ Component: NextPageWithLayout;
+};
+
+export default function StaticApp({ Component, pageProps }: AppPropsWithLayout) {
// Cookie Manager Provider Configuration
const [isMounted, setIsMounted] = useState(false);
const trackingPreference = useRef();
@@ -99,6 +111,10 @@ export default function StaticApp({ Component, pageProps }: AppProps) {
useSprig(sprigEnvironmentId);
+ const getLayout =
+ Component.getLayout ??
+ ((page) => {page});
+
if (!isMounted) return null;
return (
@@ -115,13 +131,18 @@ export default function StaticApp({ Component, pageProps }: AppProps) {
-
-
-
-
-
-
-
+
+
+
+
+ {getLayout()}
+
+
+
+
diff --git a/apps/web/pages/api/addresses/[address]/proof/index.ts b/apps/web/pages/api/addresses/[address]/proof/index.ts
deleted file mode 100644
index 7e431ea..0000000
--- a/apps/web/pages/api/addresses/[address]/proof/index.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { NextApiRequest, NextApiResponse } from 'next';
-import { getProofsByNamespaceAndAddress, ProofTableNamespace } from 'apps/web/src/utils/proofs';
-
-export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- if (req.method !== 'GET') {
- return res.status(405).json({ error: 'method not allowed' });
- }
- const { address, namespace } = req.query;
- if (!address) {
- return res.status(400).json({ error: 'address is required' });
- }
-
- try {
- const content = await getProofsByNamespaceAndAddress(
- address as string,
- namespace as ProofTableNamespace,
- );
-
- return res.status(200).json({ result: content });
- } catch (error) {
- console.error(error);
- }
-
- return res.status(404).json({ error: 'address is not eligible for this project' });
-}
diff --git a/apps/web/pages/api/basenames/[name]/assets/cardImage.svg.tsx b/apps/web/pages/api/basenames/[name]/assets/cardImage.svg.tsx
new file mode 100644
index 0000000..83cc39d
--- /dev/null
+++ b/apps/web/pages/api/basenames/[name]/assets/cardImage.svg.tsx
@@ -0,0 +1,111 @@
+import satori from 'satori';
+import { NextRequest } from 'next/server';
+import { getUserNamePicture } from 'apps/web/src/utils/usernames';
+import twemoji from 'twemoji';
+
+const emojiCache: Record> = {};
+
+export async function loadEmoji(emojiString: string) {
+ const code = twemoji.convert.toCodePoint(emojiString);
+
+ if (code in emojiCache) {
+ return emojiCache[code];
+ }
+
+ // TODO: Is this okay? Vercel's OG image already does these calls
+ const url = `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${code.toLowerCase()}.svg`;
+
+ return (emojiCache[code] = fetch(url).then(async (r) => r.text()));
+}
+
+export const config = {
+ runtime: 'edge',
+};
+
+// TODO: Do we want to check if the name actually exists?
+export default async function handler(request: NextRequest) {
+ const fontData = await fetch(
+ new URL('../../../../../src/fonts/CoinbaseDisplay-Regular.ttf', import.meta.url),
+ ).then(async (res) => res.arrayBuffer());
+
+ // TODO: Check this works in live/production
+ const url = new URL(request.url);
+ const username = url.searchParams.get('name') ?? 'yourname';
+ const domainName = `${url.protocol}//${url.host}`;
+ const profilePicture = getUserNamePicture(username);
+
+ // Using Satori for a SVG response
+ const svg = await satori(
+
+
+
+
+
+
+ {username}
+
+
+
,
+ {
+ width: 1000,
+ height: 1000,
+ fonts: [
+ {
+ name: 'CoinbaseDisplay',
+ data: fontData,
+ weight: 500,
+ style: 'normal',
+ },
+ ],
+ loadAdditionalAsset: async (code: string, segment: string) => {
+ if (code === 'emoji') {
+ return `data:image/svg+xml;base64,${btoa(await loadEmoji(segment))}`;
+ }
+
+ return code;
+ },
+ },
+ );
+
+ return new Response(svg, {
+ headers: {
+ 'Content-Type': 'image/svg+xml',
+ },
+ });
+}
diff --git a/apps/web/pages/api/basenames/[name]/assets/coverImage.png.tsx b/apps/web/pages/api/basenames/[name]/assets/coverImage.png.tsx
new file mode 100644
index 0000000..70d5460
--- /dev/null
+++ b/apps/web/pages/api/basenames/[name]/assets/coverImage.png.tsx
@@ -0,0 +1,86 @@
+import { ImageResponse } from '@vercel/og';
+import { getUserNamePicture } from 'apps/web/src/utils/usernames';
+import { NextRequest } from 'next/server';
+import tempPendingAnimation from 'apps/web/src/components/Basenames/tempPendingAnimation.png';
+import { openGraphImageHeight, openGraphImageWidth } from 'apps/web/src/utils/opengraphs';
+
+export const config = {
+ runtime: 'edge',
+};
+
+// TODO: Do we want to check if the name actually exists?
+export default async function handler(request: NextRequest) {
+ const fontData = await fetch(
+ new URL('../../../../../src/fonts/CoinbaseDisplay-Regular.ttf', import.meta.url),
+ ).then(async (res) => res.arrayBuffer());
+
+ // TODO: Check this works in live/production
+ const url = new URL(request.url);
+ const username = url.searchParams.get('name') ?? 'yourname';
+ const domainName = `${url.protocol}//${url.host}`;
+ const profilePicture = getUserNamePicture(username);
+
+ // Using vercel's OG image for a PNG response
+ return new ImageResponse(
+ (
+
+
+
+
+
+
+ {username}
+
+
+
+ ),
+ {
+ width: openGraphImageWidth,
+ height: openGraphImageHeight,
+ fonts: [
+ {
+ name: 'Typewriter',
+ data: fontData,
+ style: 'normal',
+ },
+ ],
+ },
+ );
+}
diff --git a/apps/web/pages/api/basenames/avatar/upload.ts b/apps/web/pages/api/basenames/avatar/upload.ts
new file mode 100644
index 0000000..5de5da5
--- /dev/null
+++ b/apps/web/pages/api/basenames/avatar/upload.ts
@@ -0,0 +1,51 @@
+import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
+import type { NextApiResponse, NextApiRequest } from 'next';
+
+export const ALLOWED_IMAGE_TYPE = [
+ 'image/svg+xml',
+ 'image/png',
+ 'image/jpeg',
+ 'image/webp',
+ 'image/gif',
+];
+
+export const MAX_IMAGE_SIZE_IN_MB = 1; // max 1mb
+
+export default async function handler(request: NextApiRequest, response: NextApiResponse) {
+ const body = request.body as HandleUploadBody;
+ const username = request.query.username;
+ if (!username) return;
+
+ try {
+ const jsonResponse = await handleUpload({
+ body,
+ request,
+ onBeforeGenerateToken: async (pathname) => {
+ // TODO: We can maybe compare username to an address for additional security
+ // Currently this endpoints allows anonymous upload
+
+ // This should prevent random upload(s), but does not authenticate the source
+ if (!pathname.includes(`basenames/avatar/${username}`)) {
+ throw new Error('Issue with upload');
+ }
+
+ return {
+ pathname: pathname,
+ allowedContentTypes: ALLOWED_IMAGE_TYPE,
+ maximumSizeInBytes: MAX_IMAGE_SIZE_IN_MB * (1024 * 1024),
+ tokenPayload: JSON.stringify({
+ username,
+ pathname,
+ }),
+ };
+ },
+ onUploadCompleted: async () => {
+ // TODO: Maybe analytics?
+ },
+ });
+
+ return response.status(200).json(jsonResponse);
+ } catch (error) {
+ return response.status(400).json({ error: (error as Error).message });
+ }
+}
diff --git a/apps/web/pages/api/basenames/contract-uri.json.ts b/apps/web/pages/api/basenames/contract-uri.json.ts
new file mode 100644
index 0000000..76061c4
--- /dev/null
+++ b/apps/web/pages/api/basenames/contract-uri.json.ts
@@ -0,0 +1,29 @@
+import { NextResponse } from 'next/server';
+import { base } from 'viem/chains';
+
+export const config = {
+ runtime: 'edge',
+};
+
+export default async function GET(request: Request) {
+ // TODO: Check this works in live/production
+ const url = new URL(request.url);
+ const domainName = `${url.protocol}//${url.host}`;
+
+ const chainId = url.searchParams.get('chainId') ?? base.id;
+ if (!chainId) return NextResponse.json({ error: '406: chainId is missing' }, { status: 406 });
+
+ const name = Number(chainId) === base.id ? 'Basename' : 'Basename (Sepolia testnet)';
+
+ const tokenMedata = {
+ name: name,
+ description: 'TODO',
+ image: 'TODO', // TODO
+ banner_image: 'TODO.png', // TODO
+ featured_image: 'TODO.png', // TODO
+ external_link: `${domainName}/name`,
+ collaborators: [],
+ };
+
+ return NextResponse.json(tokenMedata);
+}
diff --git a/apps/web/pages/api/basenames/contract-uri.ts b/apps/web/pages/api/basenames/contract-uri.ts
new file mode 100644
index 0000000..9c983b0
--- /dev/null
+++ b/apps/web/pages/api/basenames/contract-uri.ts
@@ -0,0 +1,17 @@
+import { NextResponse } from 'next/server';
+import { base } from 'viem/chains';
+
+export const config = {
+ runtime: 'edge',
+};
+
+export default async function GET(request: Request) {
+ // TODO: Check this works in live/production
+ const url = new URL(request.url);
+ const chainId = url.searchParams.get('chainId') ?? base.id;
+ if (!chainId) return NextResponse.json({ error: '406: chainId is missing' }, { status: 406 });
+
+ return NextResponse.redirect(
+ new URL(`/api/basenames/contract-uri.json?chainId=${chainId}`, request.url),
+ );
+}
diff --git a/apps/web/pages/api/basenames/metadata/[tokenId].ts b/apps/web/pages/api/basenames/metadata/[tokenId].ts
new file mode 100644
index 0000000..06a8b7d
--- /dev/null
+++ b/apps/web/pages/api/basenames/metadata/[tokenId].ts
@@ -0,0 +1,63 @@
+import L2Resolver from 'apps/web/src/abis/L2Resolver';
+import { USERNAME_L2_RESOLVER_ADDRESSES } from 'apps/web/src/addresses/usernames';
+import { getBasenamePublicClient } from 'apps/web/src/hooks/useBasenameChain';
+import { USERNAME_DOMAINS } from 'apps/web/src/utils/usernames';
+import { NextResponse } from 'next/server';
+import { encodePacked, keccak256, namehash, toHex } from 'viem';
+import { base } from 'viem/chains';
+
+export const config = {
+ runtime: 'edge',
+};
+
+export default async function GET(request: Request) {
+ // TODO: Check this works in live/production
+ const url = new URL(request.url);
+ const domainName = `${url.protocol}//${url.host}`;
+ let tokenId = url.searchParams.get('tokenId');
+ if (tokenId?.endsWith('.json')) tokenId = tokenId.slice(0, -5);
+
+ const chainId = url.searchParams.get('chainId') ?? base.id;
+ const baseDomainName = USERNAME_DOMAINS[Number(chainId)];
+
+ if (!tokenId) return NextResponse.json({ error: '406: tokenId is missing' }, { status: 406 });
+ if (!chainId) return NextResponse.json({ error: '406: chainId is missing' }, { status: 406 });
+ if (!baseDomainName)
+ return NextResponse.json({ error: '406: base domain name is missing' }, { status: 406 });
+
+ // Get labelhash from tokenId
+ const labelHash = toHex(BigInt(tokenId));
+
+ // Convert labelhash to namehash
+ const namehashNode = keccak256(
+ encodePacked(['bytes32', 'bytes32'], [namehash(baseDomainName), labelHash]),
+ );
+
+ const client = getBasenamePublicClient(Number(chainId));
+ const basename = await client.readContract({
+ abi: L2Resolver,
+ address: USERNAME_L2_RESOLVER_ADDRESSES[Number(chainId)],
+ args: [namehashNode],
+ functionName: 'name',
+ });
+
+ if (!basename) return NextResponse.json({ error: '404: Basename not found' }, { status: 404 });
+
+ const tokenMedata = {
+ // This is the URL to the image of the item.
+ image: `${domainName}/api/basenames/${basename}/assets/cardImage.svg`,
+
+ // This is the URL that will appear below the asset's image on OpenSea and will allow users to leave OpenSea and view the item on your site.
+ external_url: `${domainName}/name/${basename}`,
+
+ // A human-readable description of the item. Markdown is supported.
+ description: `${basename}, a Basename`,
+
+ // A human-readable description of the item. Markdown is supported.
+ name: basename,
+
+ // TODO: attributes?
+ };
+
+ return NextResponse.json(tokenMedata);
+}
diff --git a/apps/web/pages/api/basenames/talentprotocol/[address].ts b/apps/web/pages/api/basenames/talentprotocol/[address].ts
new file mode 100644
index 0000000..804519a
--- /dev/null
+++ b/apps/web/pages/api/basenames/talentprotocol/[address].ts
@@ -0,0 +1,35 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== 'GET') {
+ res.status(405).json({ error: 'method not allowed' });
+ return;
+ }
+ const { address } = req.query;
+
+ if (typeof address !== 'string') {
+ res.status(400).json({ error: 'address is required' });
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `https://api.talentprotocol.com/api/v2/passports/${encodeURIComponent(address || '')}`,
+ {
+ method: 'GET',
+ headers: {
+ 'X-API-KEY': process.env.TALENT_PROTOCOL_API_KEY,
+ },
+ },
+ );
+ const data = await response.json();
+
+ if (data) {
+ return res.status(200).json(data);
+ }
+ } catch (error) {
+ console.error(error);
+ }
+
+ return res.status(404).json({ error: 'address not found' });
+}
diff --git a/apps/web/pages/api/name/[alreadyClaimedName].ts b/apps/web/pages/api/name/[alreadyClaimedName].ts
new file mode 100644
index 0000000..71fdbaa
--- /dev/null
+++ b/apps/web/pages/api/name/[alreadyClaimedName].ts
@@ -0,0 +1,62 @@
+import { queryCbGpt } from 'apps/web/src/cdp/api/cb-gpt';
+import { NextApiRequest, NextApiResponse } from 'next';
+
+export type NameSuggestionResponseData = {
+ suggestion: string[];
+};
+
+type ErrorResponseData = {
+ error: string;
+};
+
+type ApiResponse = NameSuggestionResponseData | ErrorResponseData;
+
+const NAME_COUNT = 3;
+const SYSTEM_PROMPT = `You are an AI assistant tasked with providing alternative username recommendations
+for the Ethereum Name Service (ENS). Users come to you when their desired ENS name is unavailable,
+and you need to suggest alternative names that are both desirable and likely to be available.
+You will be given one input. This is the name that the user originally wanted but is unavailable.
+Follow these guidelines when generating alternative names:
+1. Create names that are similar in style or
+meaning to the unavailable name.
+2. Suggestions should be very unlikely to already be taken.
+3. You may use emoji in your suggestions.
+4. Do not include any suffixes (i.e., .ens) in your suggestions.
+5. Keep the names short and memorable.
+6. Be creative and think of unique variations that the user might find appealing.
+Your output should be a JSON array containing exactly ${NAME_COUNT} alternative name suggestions. Do not
+include any explanation or additional text outside of the JSON array.
+Remember, the goal is to provide alternatives that users will find desirable and that are likely to be available on ENS.
+Focus on quality and creativity in your suggestions.`;
+const chatLlm = 'claude-3-5-sonnet@20240620';
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const { alreadyClaimedName } = req.query;
+ if (typeof alreadyClaimedName !== 'string') {
+ res.status(400).json({ error: 'name must be a string' });
+ return;
+ }
+ if (alreadyClaimedName.length > 50) {
+ res.status(400).json({ error: 'name too long to fetch recommendations' });
+ return;
+ }
+ try {
+ const suggestion = await queryCbGpt({
+ taskConfig: {
+ actionLlm: {
+ chatLlm: chatLlm,
+ },
+ action_prompt_template: {
+ init_llm_chain: SYSTEM_PROMPT,
+ },
+ },
+ query: alreadyClaimedName,
+ });
+ res.status(200).json({ suggestion: JSON.parse(suggestion.response) as string[] });
+ } catch (e) {
+ console.error(e);
+ if (e instanceof Error) {
+ res.status(500).json({ error: `failed to generate suggestions ${e.message}` });
+ }
+ }
+}
diff --git a/apps/web/pages/api/paymaster/index.ts b/apps/web/pages/api/paymaster/index.ts
new file mode 100644
index 0000000..b77a52c
--- /dev/null
+++ b/apps/web/pages/api/paymaster/index.ts
@@ -0,0 +1,40 @@
+import { UserOperation } from 'permissionless';
+import { paymasterClient } from '../../../src/utils/paymasterConfig';
+import { willSponsor } from '../../../src/utils/paymasterSponsor';
+import type { NextApiRequest, NextApiResponse } from 'next';
+
+type RequestBody = {
+ params: [
+ UserOperation<'v0.6'>, // userOp
+ string, // endpoint
+ number, // chainId
+ ];
+};
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const method = req.method;
+ if (!req.body?.params && !Array.isArray(req.body?.params)) {
+ return res.status(400).json({ error: 'invalid body' });
+ }
+ const { params } = req.body as RequestBody;
+ const [userOp, entrypoint, chainId] = params;
+ try {
+ const willSponsorDecision = await willSponsor({ chainId, entrypoint, userOp });
+ if (!willSponsorDecision) {
+ return res.json({ error: 'Not a sponsorable operation' });
+ }
+
+ if (method === 'pm_getPaymasterStubData') {
+ // @ts-expect-error verified functional by @keshavSinghal
+ const result = await paymasterClient.getPaymasterStubData({ userOperation: userOp });
+ return res.json({ result });
+ } else if (method === 'pm_getPaymasterData') {
+ // @ts-expect-error verified functional by @keshavSinghal
+ const result = await paymasterClient.getPaymasterData({ userOperation: userOp });
+ return res.json({ result });
+ }
+ } catch (e) {
+ console.error(e);
+ return res.status(500).json({ error: 'something went wrong validating ' });
+ }
+}
diff --git a/apps/web/pages/api/proofs/cb1/index.ts b/apps/web/pages/api/proofs/cb1/index.ts
new file mode 100644
index 0000000..b3d62a1
--- /dev/null
+++ b/apps/web/pages/api/proofs/cb1/index.ts
@@ -0,0 +1,70 @@
+import { trustedSignerPKey } from 'apps/web/src/constants';
+import { isBasenameSupportedChain } from 'apps/web/src/hooks/useBasenameChain';
+import { DiscountType } from 'apps/web/src/utils/proofs';
+import { sybilResistantUsernameSigning } from 'apps/web/src/utils/proofs/sybil_resistance';
+import type { NextApiRequest, NextApiResponse } from 'next';
+import { isAddress } from 'viem';
+
+/**
+ * This endpoint checks if the provided address has access to the cb1 attestation.
+ *
+ * Possible Error Responses:
+ * - 400: Invalid address or missing verifications.
+ * - 405: Unauthorized method.
+ * - 409: User has already claimed a username.
+ * - 500: Internal server error.
+ *
+ * @returns {Object} - An object with the signed message, attestations, and discount validator address.
+ * Example response:
+ * {
+ * "signedMessage": "0x0000000000000000000000009c02e8e28d8b706f67dcf0fc7f46a9ee1f9649fa000000000000000000000000000000000000000000000000000000000000012c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000416f4b871a02406ddddbf6f6df1c58416830c5ce45becad5b4f30cf32f74ee39a5559659f9e29479bc76bb1ebf40fffc7119d09ed7c8dcaf6075956f83935263851b00000000000000000000000000000000000000000000000000000000000000",
+ * "attestations": [
+ * {
+ * "name": "verifiedCoinbaseOne",
+ * "type": "bool",
+ * "signature": "bool verifiedCoinbaseOne",
+ * "value": {
+ * "name": "verifiedCoinbaseOne",
+ * "type": "bool",
+ * "value": true
+ * }
+ * }
+ * ],
+ * "discountValidatorAddress": "0x502df754f25f492cad45ed85a4de0ee7540717e7"
+ * }
+ */
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== 'GET') {
+ res.status(405).json({ error: 'method not allowed' });
+ return;
+ }
+ const { address, chain } = req.query;
+
+ if (!address || Array.isArray(address) || !isAddress(address)) {
+ return res.status(400).json({ error: 'valid address is required' });
+ }
+
+ if (!trustedSignerPKey) {
+ return res.status(500).json({ error: 'currently unable to sign' });
+ }
+ if (!chain || Array.isArray(chain)) {
+ return res.status(400).json({ error: 'chain must be a single value' });
+ }
+ let parsedChain = parseInt(chain);
+ if (!isBasenameSupportedChain(parsedChain)) {
+ return res.status(400).json({ error: 'chain must be Base or Base Sepolia' });
+ }
+
+ try {
+ const result = await sybilResistantUsernameSigning(address, DiscountType.CB1, parsedChain);
+ return res.status(200).json(result);
+ } catch (error) {
+ console.error(error);
+ if (error instanceof Error) {
+ return res.status(409).json({ error: error.message });
+ }
+
+ // If error is not an instance of Error, return a generic error message
+ return res.status(409).json({ error: 'An unexpected error occurred' });
+ }
+}
diff --git a/apps/web/pages/api/proofs/cbid/index.ts b/apps/web/pages/api/proofs/cbid/index.ts
new file mode 100644
index 0000000..9feb5b2
--- /dev/null
+++ b/apps/web/pages/api/proofs/cbid/index.ts
@@ -0,0 +1,68 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+import {
+ getProofsByNamespaceAndAddress,
+ hasRegisteredWithDiscount,
+ ProofTableNamespace,
+} from 'apps/web/src/utils/proofs';
+import { Address, isAddress } from 'viem';
+import { USERNAME_CB_ID_DISCOUNT_VALIDATORS } from 'apps/web/src/addresses/usernames';
+import { isBasenameSupportedChain } from 'apps/web/src/hooks/useBasenameChain';
+
+export type CBIDProofResponse = {
+ discountValidatorAddress: Address;
+ address: Address;
+ namespace: string;
+ proofs: `0x${string}`[];
+};
+
+/*
+this endpoint returns whether or not the account has a cb.id
+if result array is empty, user has no cb.id
+example return:
+{
+ "address": "0xB18e4C959bccc8EF86D78DC297fb5efA99550d85",
+ "namespace": "usernames",
+ "proofs": "[0x56ce3bbc909b90035ae373d32c56a9d81d26bb505dd935cdee6afc384bcaed8d, 0x99e940ed9482bf59ba5ceab7df0948798978a1acaee0ecb41f64fe7f40eedd17]"
+ "discountValidatorAddress": "0x..."
+}
+*/
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== 'GET') {
+ return res.status(405).json({ error: 'method not allowed' });
+ }
+ const { address, chain } = req.query;
+ if (!address || Array.isArray(address) || !isAddress(address)) {
+ return res.status(400).json({ error: 'A single valid address is required' });
+ }
+
+ if (!chain || Array.isArray(chain)) {
+ return res.status(400).json({ error: 'invalid chain' });
+ }
+ let parsedChain = parseInt(chain);
+ if (!isBasenameSupportedChain(parsedChain)) {
+ return res.status(400).json({ error: 'chain must be Base or Base Sepolia' });
+ }
+
+ try {
+ const hasPreviouslyRegistered = await hasRegisteredWithDiscount([address], parsedChain);
+ // if any linked address registered previously return an error
+ if (hasPreviouslyRegistered) {
+ return res.status(400).json({ error: 'This address has already claimed a username.' });
+ }
+ const [content] = await getProofsByNamespaceAndAddress(address, ProofTableNamespace.Usernames);
+ const proofs = content?.proofs ? (JSON.parse(content.proofs) as `0x${string}`[]) : [];
+ if (proofs.length === 0) {
+ return res.status(404).json({ error: 'address is not eligible for a cbid discount' });
+ }
+ const responseData: CBIDProofResponse = {
+ ...content,
+ proofs,
+ discountValidatorAddress: USERNAME_CB_ID_DISCOUNT_VALIDATORS[parsedChain],
+ };
+ return res.status(200).json(responseData);
+ } catch (error: unknown) {
+ console.error(error);
+ }
+
+ return res.status(404).json({ error: 'address is not eligible for a cbid discount' });
+}
diff --git a/apps/web/pages/api/proofs/coinbase/index.ts b/apps/web/pages/api/proofs/coinbase/index.ts
new file mode 100644
index 0000000..ec3c245
--- /dev/null
+++ b/apps/web/pages/api/proofs/coinbase/index.ts
@@ -0,0 +1,71 @@
+import { trustedSignerPKey } from 'apps/web/src/constants';
+import { isBasenameSupportedChain } from 'apps/web/src/hooks/useBasenameChain';
+import { DiscountType, VerifiedAccount } from 'apps/web/src/utils/proofs';
+import { sybilResistantUsernameSigning } from 'apps/web/src/utils/proofs/sybil_resistance';
+import type { NextApiRequest, NextApiResponse } from 'next';
+import { Address, isAddress } from 'viem';
+
+// Coinbase verified account *and* CB1 structure
+export type CoinbaseProofResponse = {
+ signedMessage?: string;
+ attestations: VerifiedAccount[];
+ discountValidatorAddress: Address;
+ expires?: string;
+};
+
+/**
+ * This endpoint reports whether or not the provided access has access to the verified account attestation
+ *
+ * Error responses:
+ * 400: if address is invalid or missing verifications
+ * 405: for unauthorized methods
+ * 409: if user has already claimed a username
+ * 500: for internal server errors
+ *
+ * @param req
+ * {
+ * address: address to check if user is allowed to claim a new username with discount
+ * }
+ * @param res
+ * {
+ * signedMessage: this is to be passed into the contract to claim a username
+ * attestations: will show the attestations that the user has for verified account and verified cb1 account
+ * }
+ * @returns
+ */
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== 'GET') {
+ res.status(405).json({ error: 'method not allowed' });
+ return;
+ }
+ const { address, chain } = req.query;
+
+ if (!address || Array.isArray(address) || !isAddress(address)) {
+ return res.status(400).json({ error: 'valid address is required' });
+ }
+
+ if (!trustedSignerPKey) {
+ return res.status(500).json({ error: 'currently unable to sign' });
+ }
+
+ if (!chain || Array.isArray(chain)) {
+ return res.status(400).json({ error: 'chain must be a single value' });
+ }
+ let parsedChain = parseInt(chain);
+ if (!isBasenameSupportedChain(parsedChain)) {
+ return res.status(400).json({ error: 'chain must be Base or Base Sepolia' });
+ }
+
+ try {
+ const result = await sybilResistantUsernameSigning(address, DiscountType.CB, parsedChain);
+ return res.status(200).json(result);
+ } catch (error) {
+ console.error(error);
+ if (error instanceof Error) {
+ return res.status(409).json({ error: error.message });
+ }
+
+ // If error is not an instance of Error, return a generic error message
+ return res.status(409).json({ error: 'An unexpected error occurred' });
+ }
+}
diff --git a/apps/web/pages/api/proofs/earlyAccess/index.ts b/apps/web/pages/api/proofs/earlyAccess/index.ts
new file mode 100644
index 0000000..f4bbf30
--- /dev/null
+++ b/apps/web/pages/api/proofs/earlyAccess/index.ts
@@ -0,0 +1,81 @@
+import { USERNAME_EA_DISCOUNT_VALIDATORS } from 'apps/web/src/addresses/usernames';
+import { isBasenameSupportedChain } from 'apps/web/src/hooks/useBasenameChain';
+import {
+ getProofsByNamespaceAndAddress,
+ hasRegisteredWithDiscount,
+ ProofTableNamespace,
+} from 'apps/web/src/utils/proofs';
+import { NextApiRequest, NextApiResponse } from 'next';
+import { Address, isAddress } from 'viem';
+
+export type EarlyAccessProofResponse = {
+ discountValidatorAddress: Address;
+ address: Address;
+ namespace: string;
+ proofs: `0x${string}`[];
+};
+
+/*
+this endpoint returns whether or not the account has a cb.id
+if result array is empty, user has no cb.id
+example return:
+{
+ "address": "0xB18e4C959bccc8EF86D78DC297fb5efA99550d85",
+ "namespace": "usernames",
+ "proofs": "[0x56ce3bbc909b90035ae373d32c56a9d81d26bb505dd935cdee6afc384bcaed8d, 0x99e940ed9482bf59ba5ceab7df0948798978a1acaee0ecb41f64fe7f40eedd17]"
+ "discountValidatorAddress": "0x..."
+}
+*/
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== 'GET') {
+ return res.status(405).json({ error: 'method not allowed' });
+ }
+ const { address, chain } = req.query;
+ if (!address || Array.isArray(address) || !isAddress(address)) {
+ return res.status(400).json({ error: 'A single valid address is required' });
+ }
+
+ if (!chain || Array.isArray(chain)) {
+ return res.status(400).json({ error: 'invalid chain' });
+ }
+
+ let parsedChain = parseInt(chain);
+ if (!isBasenameSupportedChain(parsedChain)) {
+ return res.status(400).json({ error: 'chain must be Base or Base Sepolia' });
+ }
+
+ if (!isBasenameSupportedChain(parsedChain)) {
+ return res.status(400).json({ error: 'chain must be Base or Base Sepolia' });
+ }
+
+ try {
+ const hasPreviouslyRegistered = await hasRegisteredWithDiscount([address], parsedChain);
+
+ // if any linked address registered previously return an error
+ if (hasPreviouslyRegistered) {
+ return res.status(400).json({ error: 'This address has already claimed a username.' });
+ }
+ const [content] = await getProofsByNamespaceAndAddress(
+ address,
+ ProofTableNamespace.UsernamesEarlyAccess,
+ );
+
+ const proofs = content?.proofs ? (JSON.parse(content.proofs) as `0x${string}`[]) : [];
+ if (proofs.length === 0) {
+ return res.status(404).json({ error: 'address is not eligible for early access' });
+ }
+
+ const responseData: EarlyAccessProofResponse = {
+ ...content,
+ proofs,
+ discountValidatorAddress: USERNAME_EA_DISCOUNT_VALIDATORS[parsedChain],
+ };
+
+ console.log({ responseData });
+ return res.status(200).json(responseData);
+ } catch (error: unknown) {
+ console.error(error);
+ }
+
+ return res.status(404).json({ error: 'address is not eligible for early access' });
+}
diff --git a/apps/web/pages/ecosystem.tsx b/apps/web/pages/ecosystem.tsx
index 62ba5fe..553bf01 100644
--- a/apps/web/pages/ecosystem.tsx
+++ b/apps/web/pages/ecosystem.tsx
@@ -1,7 +1,7 @@
import EcosystemHeroLogos from 'apps/web/public/images/ecosystem-hero-logos-new.png';
import { Divider } from 'apps/web/src/components/Divider/Divider';
import { List } from 'apps/web/src/components/Ecosystem/List';
-import { Button } from 'apps/web/src/components/Button/Button';
+import { Button, ButtonVariants } from 'apps/web/src/components/Button/Button';
import Head from 'next/head';
import Image from 'next/image';
@@ -21,7 +21,7 @@ function EcosystemHero() {
target="_blank"
rel="noreferrer noopener"
>
-