diff --git a/apps/web/app/(base-org)/frames/names/page.tsx b/apps/web/app/(base-org)/frames/names/page.tsx new file mode 100644 index 0000000000..29366aa7d9 --- /dev/null +++ b/apps/web/app/(base-org)/frames/names/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from 'next'; +import Image from 'apps/web/node_modules/next/image'; +import Link from 'apps/web/node_modules/next/link'; +import initialFrameImage from 'apps/web/pages/api/basenames/frame/assets/initial-image.png'; +import { initialFrame } from 'apps/web/pages/api/basenames/frame/frameResponses'; + +export const metadata: Metadata = { + metadataBase: new URL('https://base.org'), + title: `Basenames | Frame`, + description: + 'Basenames are a core onchain building block that enables anyone to establish their identity on Base by registering human-readable names for their address(es). They are a fully onchain solution which leverages ENS infrastructure deployed on Base.', + openGraph: { + title: `Basenames | Frame`, + url: `/frames/names`, + images: [initialFrameImage.src], + }, + twitter: { + site: '@base', + card: 'summary_large_image', + }, + other: { + ...(initialFrame as Record), + }, +}; + +export default async function NameFrame() { + return ( +
+
+
+ + Claim a basename today + +
+
+
+ ); +} diff --git a/apps/web/pages/api/basenames/[name]/getBasenameRegistrationPrice.ts b/apps/web/pages/api/basenames/[name]/getBasenameRegistrationPrice.ts new file mode 100644 index 0000000000..474f1f9a4b --- /dev/null +++ b/apps/web/pages/api/basenames/[name]/getBasenameRegistrationPrice.ts @@ -0,0 +1,57 @@ +import { NextApiRequest, NextApiResponse } from 'apps/web/node_modules/next/dist/shared/lib/utils'; +import { createPublicClient, http } from 'viem'; +import { base } from 'viem/chains'; +import { + REGISTER_CONTRACT_ABI, + REGISTER_CONTRACT_ADDRESSES, + normalizeName, +} from 'apps/web/src/utils/usernames'; +import { weiToEth } from 'apps/web/src/utils/weiToEth'; +import { formatWei } from 'apps/web/src/utils/formatWei'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { name, years } = req.query; + + try { + const registrationPrice = await getBasenameRegistrationPrice(String(name), Number(years)); + if (!registrationPrice) { + throw new Error('Could not get registration price.'); + } + + const registrationPriceInWei = formatWei(registrationPrice).toString(); + const registrationPriceInEth = weiToEth(registrationPrice).toString(); + return res.status(200).json({ registrationPriceInWei, registrationPriceInEth }); + } catch (error) { + console.error('Could not get registration price: ', error); + return res.status(500).json(error); + } +} + +async function getBasenameRegistrationPrice(name: string, years: number): Promise { + const client = createPublicClient({ + chain: base, + transport: http(), + }); + try { + const normalizedName = normalizeName(name); + if (!normalizedName) { + throw new Error('Invalid ENS domain name'); + } + + const price = await client.readContract({ + address: REGISTER_CONTRACT_ADDRESSES[base.id], + abi: REGISTER_CONTRACT_ABI, + functionName: 'registerPrice', + args: [normalizedName, secondsInYears(years)], + }); + return price; + } catch (error) { + console.error('Could not get claim price:', error); + return null; + } +} + +function secondsInYears(years: number) { + const secondsPerYear = 365.25 * 24 * 60 * 60; // .25 accounting for leap years + return BigInt(Math.round(years * secondsPerYear)); +} diff --git a/apps/web/pages/api/basenames/[name]/isNameAvailable.ts b/apps/web/pages/api/basenames/[name]/isNameAvailable.ts new file mode 100644 index 0000000000..401d760865 --- /dev/null +++ b/apps/web/pages/api/basenames/[name]/isNameAvailable.ts @@ -0,0 +1,21 @@ +import { NextApiRequest, NextApiResponse } from 'apps/web/node_modules/next/dist/shared/lib/utils'; +import { base } from 'viem/chains'; +import { getBasenameAvailable } from 'apps/web/src/utils/usernames'; + +export type IsNameAvailableResponse = { + nameIsAvailable: boolean; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { name } = req.query; + try { + const isNameAvailableResponse = await getBasenameAvailable(String(name), base); + const responseData: IsNameAvailableResponse = { + nameIsAvailable: isNameAvailableResponse, + }; + return res.status(200).json(responseData); + } catch (error) { + console.error('Could not read name availability:', error); + return res.status(500).json({ error: 'Could not determine name availability' }); + } +} diff --git a/apps/web/pages/api/basenames/frame/01_inputSearchValue.ts b/apps/web/pages/api/basenames/frame/01_inputSearchValue.ts new file mode 100644 index 0000000000..51d9fcfbd4 --- /dev/null +++ b/apps/web/pages/api/basenames/frame/01_inputSearchValue.ts @@ -0,0 +1,15 @@ +import { NextApiRequest, NextApiResponse } from 'next/dist/shared/lib/utils'; +import { inputSearchValueFrame } from 'apps/web/pages/api/basenames/frame/frameResponses'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: `Search Screen — Method (${req.method}) Not Allowed` }); + } + + try { + return res.status(200).setHeader('Content-Type', 'text/html').send(inputSearchValueFrame); + } catch (error) { + console.error('Could not process request:', error); + return res.status(500).json({ error: 'Internal Server Error' }); + } +} diff --git a/apps/web/pages/api/basenames/frame/02_validateSearchInputAndSetYears.ts b/apps/web/pages/api/basenames/frame/02_validateSearchInputAndSetYears.ts new file mode 100644 index 0000000000..3259b6f510 --- /dev/null +++ b/apps/web/pages/api/basenames/frame/02_validateSearchInputAndSetYears.ts @@ -0,0 +1,49 @@ +import { NextApiRequest, NextApiResponse } from 'next/dist/shared/lib/utils'; +import { FrameRequest } from '@coinbase/onchainkit/frame'; +import { formatDefaultUsername, validateEnsDomainName } from 'apps/web/src/utils/usernames'; +import type { IsNameAvailableResponse } from 'apps/web/pages/api/basenames/[name]/isNameAvailable'; +import { + retryInputSearchValueFrame, + setYearsFrame, +} from 'apps/web/pages/api/basenames/frame/frameResponses'; +import { DOMAIN } from 'apps/web/pages/api/basenames/frame/constants'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: `Set Years Screen — Method (${req.method}) Not Allowed` }); + } + + try { + const body = req.body as FrameRequest; + const { untrustedData } = body; + const targetName: string = encodeURIComponent(untrustedData.inputText); + + const { valid, message } = validateEnsDomainName(targetName); + if (!valid) { + return res + .status(200) + .setHeader('Content-Type', 'text/html') + .send(retryInputSearchValueFrame(message)); + } + + const isNameAvailableResponse = await fetch( + `${DOMAIN}/api/basenames/${targetName}/isNameAvailable`, + ); + const isNameAvailableResponseData = await isNameAvailableResponse.json(); + const { nameIsAvailable } = isNameAvailableResponseData as IsNameAvailableResponse; + if (!nameIsAvailable) { + return res + .status(200) + .setHeader('Content-Type', 'text/html') + .send(retryInputSearchValueFrame('Name unavailable')); + } + + const formattedTargetName = await formatDefaultUsername(targetName); + return res + .status(200) + .setHeader('Content-Type', 'text/html') + .send(setYearsFrame(targetName, formattedTargetName)); + } catch (error) { + return res.status(500).json({ error }); // TODO: figure out error state for the frame BAPP-452 + } +} diff --git a/apps/web/pages/api/basenames/frame/03_getPriceAndConfirm.ts b/apps/web/pages/api/basenames/frame/03_getPriceAndConfirm.ts new file mode 100644 index 0000000000..5cb2fc7c53 --- /dev/null +++ b/apps/web/pages/api/basenames/frame/03_getPriceAndConfirm.ts @@ -0,0 +1,64 @@ +import { NextApiRequest, NextApiResponse } from 'next/dist/shared/lib/utils'; +import { FrameRequest } from '@coinbase/onchainkit/frame'; +import { + confirmationFrame, + buttonIndexToYears, +} from 'apps/web/pages/api/basenames/frame/frameResponses'; +import { DOMAIN } from 'apps/web/pages/api/basenames/frame/constants'; + +type ButtonIndex = 1 | 2 | 3 | 4; +const validButtonIndexes: readonly ButtonIndex[] = [1, 2, 3, 4] as const; + +type GetBasenameRegistrationPriceResponseType = { + registrationPriceInWei: string; + registrationPriceInEth: string; +}; + +type ConfirmationFrameStateType = { + targetName: string; + formattedTargetName: string; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: `Confirm Screen — Method (${req.method}) Not Allowed` }); + } + + const body = req.body as FrameRequest; + const { untrustedData } = body; + const messageState = JSON.parse( + decodeURIComponent(untrustedData.state), + ) as ConfirmationFrameStateType; + const targetName = encodeURIComponent(messageState.targetName); + const formattedTargetName = messageState.formattedTargetName; + + const buttonIndex = untrustedData.buttonIndex as ButtonIndex; + if (!validButtonIndexes.includes(buttonIndex)) { + return res.status(500).json({ error: 'Internal Server Error' }); + } + const targetYears = buttonIndexToYears[buttonIndex]; + + const getRegistrationPriceResponse = await fetch( + `${DOMAIN}/api/basenames/${targetName}/getBasenameRegistrationPrice?years=${targetYears}`, + ); + const getRegistrationPriceResponseData = await getRegistrationPriceResponse.json(); + const { registrationPriceInWei, registrationPriceInEth } = + getRegistrationPriceResponseData as GetBasenameRegistrationPriceResponseType; + + try { + return res + .status(200) + .setHeader('Content-Type', 'text/html') + .send( + confirmationFrame( + targetName, + formattedTargetName, + targetYears, + registrationPriceInWei, + registrationPriceInEth, + ), + ); + } catch (error) { + return res.status(500).json({ error: 'Internal Server Error' }); + } +} diff --git a/apps/web/pages/api/basenames/frame/04_txSuccess.ts b/apps/web/pages/api/basenames/frame/04_txSuccess.ts new file mode 100644 index 0000000000..e2c60813fa --- /dev/null +++ b/apps/web/pages/api/basenames/frame/04_txSuccess.ts @@ -0,0 +1,45 @@ +import { NextApiRequest, NextApiResponse } from 'next/dist/shared/lib/utils'; +import { FrameRequest, getFrameMessage } from '@coinbase/onchainkit/frame'; +import { txSuccessFrame } from 'apps/web/pages/api/basenames/frame/frameResponses'; +import { NEYNAR_API_KEY } from 'apps/web/pages/api/basenames/frame/constants'; +import type { TxFrameStateType } from 'apps/web/pages/api/basenames/frame/tx'; + +if (!NEYNAR_API_KEY) { + throw new Error('missing NEYNAR_API_KEY'); +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: `TxSuccess Screen — Method (${req.method}) Not Allowed` }); + } + + const body = req.body as FrameRequest; + let message; + let isValid; + let name; + + try { + const result = await getFrameMessage(body, { + neynarApiKey: NEYNAR_API_KEY, + }); + isValid = result.isValid; + message = result.message; + if (!isValid) { + throw new Error('Message is not valid'); + } + if (!message) { + throw new Error('No message received'); + } + + const messageState = JSON.parse( + decodeURIComponent(message.state?.serialized), + ) as TxFrameStateType; + if (!messageState) { + throw new Error('No message state received'); + } + name = messageState.targetName; + return res.status(200).setHeader('Content-Type', 'text/html').send(txSuccessFrame(name)); + } catch (e) { + return res.status(500).json({ error: e }); + } +} diff --git a/apps/web/pages/api/basenames/frame/assets/initial-image.png b/apps/web/pages/api/basenames/frame/assets/initial-image.png new file mode 100644 index 0000000000..0a45cf0aa7 Binary files /dev/null and b/apps/web/pages/api/basenames/frame/assets/initial-image.png differ diff --git a/apps/web/pages/api/basenames/frame/assets/registration-bg.png b/apps/web/pages/api/basenames/frame/assets/registration-bg.png new file mode 100644 index 0000000000..69588270d3 Binary files /dev/null and b/apps/web/pages/api/basenames/frame/assets/registration-bg.png differ diff --git a/apps/web/pages/api/basenames/frame/assets/registrationFrameImage.png.tsx b/apps/web/pages/api/basenames/frame/assets/registrationFrameImage.png.tsx new file mode 100644 index 0000000000..d7350a3693 --- /dev/null +++ b/apps/web/pages/api/basenames/frame/assets/registrationFrameImage.png.tsx @@ -0,0 +1,146 @@ +import { ImageResponse } from '@vercel/og'; +import { NextRequest } from 'next/server'; +import { openGraphImageHeight, openGraphImageWidth } from 'apps/web/src/utils/opengraphs'; +import { getUserNamePicture } from 'apps/web/src/utils/usernames'; +import ImageRaw from 'apps/web/src/components/ImageRaw'; +import { DOMAIN } from 'apps/web/pages/api/basenames/frame/constants'; +import registrationImageBackground from 'apps/web/pages/api/basenames/frame/assets/registration-bg.png'; + +export const config = { + runtime: 'edge', +}; +const secondaryFontColor = '#0052FF'; + +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()); + + const url = new URL(request.url); + const username = url.searchParams.get('name') as string; + const profilePicture = getUserNamePicture(username); + let imageSource = DOMAIN + profilePicture.src; + const years = url.searchParams.get('years'); + const priceInEth = url.searchParams.get('priceInEth'); + + return new ImageResponse( + ( +
+
+
+ +
+ + {username} + +
+
+ {!years && ( + + How long do you want to register this name? + + )} + {years && ( + + Register for: {years} years + + )} + {priceInEth && ( + + Cost: {priceInEth} ETH + + )} +
+
+ ), + { + width: openGraphImageWidth, + height: openGraphImageHeight, + fonts: [ + { + name: 'Typewriter', + data: fontData, + style: 'normal', + }, + ], + }, + ); +} diff --git a/apps/web/pages/api/basenames/frame/assets/retry-search-image.png b/apps/web/pages/api/basenames/frame/assets/retry-search-image.png new file mode 100644 index 0000000000..8009fea35f Binary files /dev/null and b/apps/web/pages/api/basenames/frame/assets/retry-search-image.png differ diff --git a/apps/web/pages/api/basenames/frame/assets/retrySearchFrameImage.png.tsx b/apps/web/pages/api/basenames/frame/assets/retrySearchFrameImage.png.tsx new file mode 100644 index 0000000000..25bf043150 --- /dev/null +++ b/apps/web/pages/api/basenames/frame/assets/retrySearchFrameImage.png.tsx @@ -0,0 +1,135 @@ +import { ImageResponse } from '@vercel/og'; +import { NextRequest } from 'next/server'; +import { openGraphImageHeight, openGraphImageWidth } from 'apps/web/src/utils/opengraphs'; +import { RawErrorStrings } from 'apps/web/src/utils/frames/basenames'; +import { DOMAIN } from 'apps/web/pages/api/basenames/frame/constants'; +import retrySearchImageBackground from 'apps/web/pages/api/basenames/frame/assets/retry-search-image.png'; + +export const config = { + runtime: 'edge', +}; + +const secondaryFontColor = '#0052FF'; +const divStyle = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', +}; + +const errorMap: Record = { + [RawErrorStrings.Unavailable]: ( +
+ Sorry, that name is unavailable. +
+ Search for another name +
+ ), + [RawErrorStrings.TooShort]: ( +
+ Sorry, that name is too short. +
+ Search for another name +
+ ), + [RawErrorStrings.TooLong]: ( +
+ Sorry, that name is too long. +
+ Search for another name +
+ ), + [RawErrorStrings.DisallowedChars]: ( +
+ Sorry, that name uses +
+ disallowed characters. +
+ Search for another name +
+ ), + [RawErrorStrings.Invalid]: ( +
+ Sorry, that name is invalid. +
+ Search for another name +
+ ), + [RawErrorStrings.InvalidUnderscore]: ( +
+ Sorry, underscores are +
+ only allowed at the start. +
+ Search for another name +
+ ), +} as const; + +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()); + + const url = new URL(request.url); + const error = url.searchParams.get('error') as RawErrorStrings; + let errorMessage: JSX.Element | undefined; + if (error) { + errorMessage = errorMap[error] ?? ( +
+ Sorry, unable to register that name. +
+ Search for another name +
+ ); + } + + return new ImageResponse( + ( +
+
+ {errorMessage} +
+
+ ), + { + width: openGraphImageWidth, + height: openGraphImageHeight, + fonts: [ + { + name: 'Typewriter', + data: fontData, + style: 'normal', + }, + ], + }, + ); +} diff --git a/apps/web/pages/api/basenames/frame/assets/search-image.png b/apps/web/pages/api/basenames/frame/assets/search-image.png new file mode 100644 index 0000000000..3585804c2a Binary files /dev/null and b/apps/web/pages/api/basenames/frame/assets/search-image.png differ diff --git a/apps/web/pages/api/basenames/frame/constants.ts b/apps/web/pages/api/basenames/frame/constants.ts new file mode 100644 index 0000000000..9c148b28df --- /dev/null +++ b/apps/web/pages/api/basenames/frame/constants.ts @@ -0,0 +1,4 @@ +import { isDevelopment } from 'apps/web/src/constants'; + +export const DOMAIN = isDevelopment ? `http://localhost:3000` : 'https://www.base.org'; +export const NEYNAR_API_KEY = process.env.NEXT_PUBLIC_NEYNAR_API_KEY; diff --git a/apps/web/pages/api/basenames/frame/frameResponses.ts b/apps/web/pages/api/basenames/frame/frameResponses.ts new file mode 100644 index 0000000000..fa9a307dc9 --- /dev/null +++ b/apps/web/pages/api/basenames/frame/frameResponses.ts @@ -0,0 +1,123 @@ +import { getFrameMetadata, getFrameHtmlResponse } from '@coinbase/onchainkit/frame'; +import { FrameMetadataResponse } from '@coinbase/onchainkit/frame/types'; +import initialImage from 'apps/web/pages/api/basenames/frame/assets/initial-image.png'; +import searchImage from 'apps/web/pages/api/basenames/frame/assets/search-image.png'; +import { DOMAIN } from 'apps/web/pages/api/basenames/frame/constants'; + +export const initialFrame: FrameMetadataResponse = getFrameMetadata({ + buttons: [ + { + label: 'Claim', + }, + ], + image: { + src: `${DOMAIN}/${initialImage.src}`, + }, + postUrl: `${DOMAIN}/api/basenames/frame/01_inputSearchValue`, +}); + +export const inputSearchValueFrame = getFrameHtmlResponse({ + buttons: [ + { + label: 'Continue', + }, + ], + image: { + src: `${DOMAIN}/${searchImage.src}`, + }, + input: { + text: 'Search for a name', + }, + postUrl: `${DOMAIN}/api/basenames/frame/02_validateSearchInputAndSetYears`, +}); + +export const retryInputSearchValueFrame = (error?: string) => + getFrameHtmlResponse({ + buttons: [ + { + label: 'Search again', + }, + ], + image: { + src: `${DOMAIN}/api/basenames/frame/assets/retrySearchFrameImage.png?error=${error}`, + }, + input: { + text: 'Search for a name', + }, + postUrl: `${DOMAIN}/api/basenames/frame/02_validateSearchInputAndSetYears`, + }); + +export const buttonIndexToYears = { + 1: 1, + 2: 5, + 3: 10, + 4: 100, +}; + +export const setYearsFrame = (targetName: string, formattedTargetName: string) => + getFrameHtmlResponse({ + buttons: [ + { + label: '1 year', + }, + { + label: '5 years', + }, + { + label: '10 years', + }, + { + label: '100 years', + }, + ], + image: { + src: `${DOMAIN}/api/basenames/frame/assets/registrationFrameImage.png?name=${formattedTargetName}`, + }, + postUrl: `${DOMAIN}/api/basenames/frame/03_getPriceAndConfirm`, + state: { + targetName, + formattedTargetName, + }, + }); + +export const confirmationFrame = ( + targetName: string, + formattedTargetName: string, + targetYears: number, + registrationPriceInWei: string, + registrationPriceInEth: string, +) => + getFrameHtmlResponse({ + buttons: [ + { + action: 'tx', + label: `Claim name`, + target: `${DOMAIN}/api/basenames/frame/tx`, + }, + ], + image: { + src: `${DOMAIN}/api/basenames/frame/assets/registrationFrameImage.png?name=${formattedTargetName}&years=${targetYears}&priceInEth=${registrationPriceInEth}`, + }, + postUrl: `${DOMAIN}/api/basenames/frame/04_txSuccess`, + state: { + targetName, + formattedTargetName, + targetYears, + registrationPriceInWei, + registrationPriceInEth, + }, + }); + +export const txSuccessFrame = (name: string) => + getFrameHtmlResponse({ + buttons: [ + { + action: 'link', + label: `Go to your profile`, + target: `${DOMAIN}/name/${name}`, + }, + ], + image: { + src: `${DOMAIN}/images/basenames/contract-uri/feature-image.png`, + }, + }); diff --git a/apps/web/pages/api/basenames/frame/tx.ts b/apps/web/pages/api/basenames/frame/tx.ts new file mode 100644 index 0000000000..c8c6608bb4 --- /dev/null +++ b/apps/web/pages/api/basenames/frame/tx.ts @@ -0,0 +1,170 @@ +import { NextApiRequest, NextApiResponse } from 'next/dist/shared/lib/utils'; +import { + FrameRequest, + getFrameMessage, + FrameTransactionResponse, +} from '@coinbase/onchainkit/frame'; +import { encodeFunctionData, namehash } from 'viem'; +import { base } from 'viem/chains'; +import L2ResolverAbi from 'apps/web/src/abis/L2Resolver'; +import RegistrarControllerABI from 'apps/web/src/abis/RegistrarControllerABI'; +import { formatBaseEthDomain } from 'apps/web/src/utils/usernames'; +import { + USERNAME_L2_RESOLVER_ADDRESSES, + USERNAME_REGISTRAR_CONTROLLER_ADDRESSES, +} from 'apps/web/src/addresses/usernames'; +import { NEYNAR_API_KEY } from 'apps/web/pages/api/basenames/frame/constants'; + +export type TxFrameStateType = { + targetName: string; + formattedTargetName: string; + targetYears: number; + registrationPriceInWei: string; + registrationPriceInEth: string; +}; + +const RESOLVER_ADDRESS = USERNAME_L2_RESOLVER_ADDRESSES[base.id]; +const REGISTRAR_CONTROLLER_ADDRESS = USERNAME_REGISTRAR_CONTROLLER_ADDRESSES[base.id]; + +if (!NEYNAR_API_KEY) { + throw new Error('missing NEYNAR_API_KEY'); +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: `Tx Screen — Method (${req.method}) Not Allowed` }); + } + + const body = req.body as FrameRequest; + let message; + let isValid; + let name; + let years; + let priceInWei; + let claimingAddress; + + try { + const result = await getFrameMessage(body, { + neynarApiKey: NEYNAR_API_KEY, + }); + isValid = result.isValid; + message = result.message; + if (!isValid) { + throw new Error('Message is not valid'); + } + if (!message) { + throw new Error('No message received'); + } + + claimingAddress = message.address as `0x${string}`; + if (!claimingAddress) { + throw new Error('No address received'); + } + + const messageState = JSON.parse( + decodeURIComponent(message.state?.serialized), + ) as TxFrameStateType; + if (!messageState) { + throw new Error('No message state received'); + } + name = messageState.targetName; + years = messageState.targetYears; + priceInWei = messageState.registrationPriceInWei; + } catch (e) { + return res.status(500).json({ error: e }); + } + + const addressData = encodeFunctionData({ + abi: L2ResolverAbi, + functionName: 'setAddr', + args: [namehash(formatBaseEthDomain(name, base.id)), claimingAddress], + }); + + const nameData = encodeFunctionData({ + abi: L2ResolverAbi, + functionName: 'setName', + args: [namehash(formatBaseEthDomain(name, base.id)), formatBaseEthDomain(name, base.id)], + }); + + const registerRequest = { + name, + owner: claimingAddress, + duration: secondsInYears(years), + resolver: RESOLVER_ADDRESS, + data: [addressData, nameData], + reverseRecord: true, + }; + + const data = encodeFunctionData({ + abi: RegistrarControllerABI, + functionName: 'register', + args: [registerRequest], + }); + + try { + const txData: FrameTransactionResponse = { + chainId: `eip155:${base.id}`, + method: 'eth_sendTransaction', + params: { + abi: [ + { + type: 'function', + name: 'register', + inputs: [ + { + name: 'request', + type: 'tuple', + internalType: 'struct RegistrarController.RegisterRequest', + components: [ + { + name: 'name', + type: 'string', + internalType: 'string', + }, + { + name: 'owner', + type: 'address', + internalType: 'address', + }, + { + name: 'duration', + type: 'uint256', + internalType: 'uint256', + }, + { + name: 'resolver', + type: 'address', + internalType: 'address', + }, + { + name: 'data', + type: 'bytes[]', + internalType: 'bytes[]', + }, + { + name: 'reverseRecord', + type: 'bool', + internalType: 'bool', + }, + ], + }, + ], + outputs: [], + stateMutability: 'payable', + }, + ], + data, + to: REGISTRAR_CONTROLLER_ADDRESS, + value: priceInWei.toString(), + }, + }; + return res.status(200).json(txData); + } catch (error) { + return res.status(500).json({ error: 'Internal Server Error' }); + } +} + +function secondsInYears(years: number): bigint { + const secondsPerYear = 365.25 * 24 * 60 * 60; // .25 accounting for leap years + return BigInt(Math.round(years * secondsPerYear)); +} diff --git a/apps/web/src/utils/formatWei.ts b/apps/web/src/utils/formatWei.ts new file mode 100644 index 0000000000..8bd4f1086f --- /dev/null +++ b/apps/web/src/utils/formatWei.ts @@ -0,0 +1,10 @@ +import { formatEther, parseEther } from 'viem'; + +export function formatWei(wei?: bigint): number | '...' { + if (wei === undefined) { + return '...'; + } + + const priceInEth = formatEther(wei); + return parseEther(priceInEth.toString()); +} diff --git a/apps/web/src/utils/frames/basenames.ts b/apps/web/src/utils/frames/basenames.ts new file mode 100644 index 0000000000..2ae0b8675d --- /dev/null +++ b/apps/web/src/utils/frames/basenames.ts @@ -0,0 +1,8 @@ +export enum RawErrorStrings { + Unavailable = 'Name unavailable', + TooShort = 'Name is too short', + TooLong = 'Name is too long', + DisallowedChars = 'disallowed character:', + Invalid = 'Name is invalid', + InvalidUnderscore = 'underscore allowed only at start', +} diff --git a/apps/web/src/utils/usernames.ts b/apps/web/src/utils/usernames.ts index 57f74c594d..3da1f3617e 100644 --- a/apps/web/src/utils/usernames.ts +++ b/apps/web/src/utils/usernames.ts @@ -7,6 +7,8 @@ import { sha256, ContractFunctionParameters, labelhash, + createPublicClient, + http, } from 'viem'; import { normalize } from 'viem/ens'; import RegistrarControllerABI from 'apps/web/src/abis/RegistrarControllerABI'; @@ -386,8 +388,18 @@ export function getChainForBasename(username: BaseName): Chain { return username.endsWith(`.${USERNAME_DOMAINS[base.id]}`) ? base : baseSepolia; } +export function normalizeName(name: string) { + const normalizedName: string = normalizeEnsDomainName(name); + const { valid } = validateEnsDomainName(name); + + if (!valid) { + return null; + } + return normalizedName; +} + // Assume domainless name to .base.eth -export async function formatDefaultUsername(username: BaseName) { +export async function formatDefaultUsername(username: string | BaseName) { if ( username && !username.endsWith(`.${USERNAME_DOMAINS[baseSepolia.id]}`) && @@ -512,7 +524,7 @@ export function validateBasenameAvatarUrl(source: string): ValidationResult { } } -/* +/* Fetch / Api functions */ @@ -554,6 +566,30 @@ export async function getBasenameOwner(username: BaseName) { } catch (error) {} } +export async function getBasenameAvailable(name: string, chain: Chain): Promise { + try { + const client = createPublicClient({ + chain: chain, + transport: http(), + }); + const normalizedName = normalizeName(name); + if (!normalizedName) { + throw new Error('Invalid ENS domain name'); + } + + const available = await client.readContract({ + address: REGISTER_CONTRACT_ADDRESSES[base.id], + abi: REGISTER_CONTRACT_ABI, + functionName: 'available', + args: [normalizedName], + }); + return available; + } catch (error) { + console.error('Error checking name availability:', error); + throw error; + } +} + // Build a TextRecord contract request export function buildBasenameTextRecordContract( username: BaseName, @@ -594,7 +630,7 @@ export async function getBasenameTextRecords(username: BaseName) { } catch (error) {} } -/* +/* Feature flags */ diff --git a/apps/web/src/utils/weiToEth.ts b/apps/web/src/utils/weiToEth.ts new file mode 100644 index 0000000000..7efceffc7f --- /dev/null +++ b/apps/web/src/utils/weiToEth.ts @@ -0,0 +1,13 @@ +import { formatEther } from 'viem'; + +export function weiToEth(wei?: bigint): number | '...' { + if (wei === undefined) { + return '...'; + } + const eth = parseFloat(formatEther(wei)); + if (eth < 0.001) { + return parseFloat(eth.toFixed(4)); + } else { + return parseFloat(eth.toFixed(3)); + } +}