Feat/basenames frame (#862)

* early wiring for basenames frame

* renamed mint to inputSearchValue, improved logic

* changed response object

* improved flow for basenames frame

* updated metadata for frame landing page

* stronger type checking for isNameAvailable

* removed unnecessary code

* improved logic

* created API endpoint to fetch registration price

* deleted unused frame

* refactored isNameAvailable

* updated confirmation frame

* prepped through confirmation

* testing tx

* testing in staging env

* removed trailing slash from url

* created dynamic frameImage

* updates to frame response values

* linter fixes

* linter fixes

* improved error handling

* added formattedTargetName to state for confirmation frame

* updated initialSearchValueFrame to take an optional error argument

* linter fixes

* updated return value to handle errors

* improved error handling

* added error handling to frameImage

* added strict types for formatEthPrice

* linter fix

* add strict types

* linter fix

* minor type fixes

* added strict types

* removed console log

* fixed type issues and improved state handling

* added user input sanitization

* updated domain for testing

* tx test fixes

* more debugging

* debugging

* decoded message state before parsing

* logging message and message state

* updated resolver and registrar conroller addresses

* added name and address args to registration

* debugging api encoding

* added test address

* added addressData to name registration

* added nameData to registration

* added tx success screen

* linter fixes

* linter fix

* added public images

* added dynamic images and image generators

* deleted unused image generator

* constant initial search frame

* updated error handling

* updated frameResponses with new images and CTAs

* linter fix

* updated domain handling for registration image

* added error logging to capture message and messageState

* debugging background image

* restoring correct bg image for registration frame

* debugging

* allowed name to be string

* fixed type issues

* strictly typed response data

* fixed typing issues

* refactored tx frame logic

* refactored to use base.id instead of 8453

* added type for initialFrame

* improved error messaging

* export type

* updated txSuccessFrame

* updated txSuccess logic

* tx success button is now a link

* images in public directory

* reworked placeholder landing page

* explicitly typed initialFrame

* refactored to use viem instead of ethers

* refactored to use viem

* linter fixes

* updated domain logic

* updated to base instead of sepolia, removed comments

* created normalizeName utility

* implemented normalizeName, moved validation logic into try block

* undoing changes to names landing page

* undoing changes to names landing page

* updated image name and import path

* updated image import and implemented util

* updated image import

* updated image handling

* moved images out of public

* improvements from pairing session

* modifying domain for tx testing

* added enum type to raw error messages, fixed linter issues

* error message for invalid underscores

* updated neynar keys to env vars

* separate functions for formatting wei and convering wei to eth

* created file for shared constants

* updated imports

* updated imports

* updated imports

* added conditional error if no neynar key is detected

* unified domain value across pages

* updated imports

* minor refactor

* removed unused import

* reduced abi to necessary method

* better variable name
This commit is contained in:
Brendan from DeFi
2024-08-22 14:23:52 -07:00
committed by GitHub
parent c6b98ce3ac
commit 83bc4f642b
20 changed files with 937 additions and 3 deletions

View File

@@ -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<string, string>),
},
};
export default async function NameFrame() {
return (
<div className="mt-[-96px] flex w-full flex-col items-center bg-black pb-[96px]">
<div className="flex h-screen w-full max-w-[1440px] flex-col items-center justify-center gap-12 px-8 py-8 pt-28">
<div className="relative flex aspect-[993/516] h-auto w-full max-w-[1024px] flex-col items-center">
<Link href="/names">
<Image src={initialFrameImage.src} alt="Claim a basename today" fill />
</Link>
</div>
</div>
</div>
);
}

View File

@@ -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<bigint | null> {
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));
}

View File

@@ -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' });
}
}

View File

@@ -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' });
}
}

View File

@@ -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
}
}

View File

@@ -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' });
}
}

View File

@@ -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 });
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -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(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: `url(${DOMAIN + registrationImageBackground.src})`,
backgroundPosition: 'center',
backgroundSize: '100% 100%',
padding: '1.5rem',
}}
>
<div
style={{
display: 'flex',
gap: '1rem',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0455FF',
borderRadius: '5rem',
marginTop: '12%',
padding: '1rem',
paddingRight: '1.5rem',
paddingLeft: '1.5rem',
fontSize: '5rem',
maxWidth: '100%',
boxShadow:
'0px 8px 16px 0px rgba(0,82,255,0.32),inset 0px 8px 16px 0px rgba(255,255,255,0.25) ',
}}
>
<figure style={{ borderRadius: '100%', overflow: 'hidden' }}>
<ImageRaw src={imageSource} height={80} width={80} alt={username} />
</figure>
<span
style={{
color: 'white',
paddingBottom: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: 'auto',
}}
>
{username}
</span>
</div>
<div
style={{
marginTop: '3rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
{!years && (
<span
style={{
color: secondaryFontColor,
fontSize: '3rem',
paddingBottom: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: 'auto',
textAlign: 'center',
}}
>
How long do you want to register this name?
</span>
)}
{years && (
<span
style={{
color: secondaryFontColor,
fontSize: '3rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: 'auto',
textAlign: 'center',
}}
>
Register for: {years} years
</span>
)}
{priceInEth && (
<span
style={{
color: secondaryFontColor,
fontSize: '3rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: 'auto',
textAlign: 'center',
}}
>
Cost: {priceInEth} ETH
</span>
)}
</div>
</div>
),
{
width: openGraphImageWidth,
height: openGraphImageHeight,
fonts: [
{
name: 'Typewriter',
data: fontData,
style: 'normal',
},
],
},
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -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, JSX.Element> = {
[RawErrorStrings.Unavailable]: (
<div style={divStyle}>
Sorry, that name is unavailable.
<br />
Search for another name
</div>
),
[RawErrorStrings.TooShort]: (
<div style={divStyle}>
Sorry, that name is too short.
<br />
Search for another name
</div>
),
[RawErrorStrings.TooLong]: (
<div style={divStyle}>
Sorry, that name is too long.
<br />
Search for another name
</div>
),
[RawErrorStrings.DisallowedChars]: (
<div style={divStyle}>
Sorry, that name uses
<br />
disallowed characters.
<br />
Search for another name
</div>
),
[RawErrorStrings.Invalid]: (
<div style={divStyle}>
Sorry, that name is invalid.
<br />
Search for another name
</div>
),
[RawErrorStrings.InvalidUnderscore]: (
<div style={divStyle}>
Sorry, underscores are
<br />
only allowed at the start.
<br />
Search for another name
</div>
),
} 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] ?? (
<div style={divStyle}>
Sorry, unable to register that name.
<br />
Search for another name
</div>
);
}
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: `url(${DOMAIN + retrySearchImageBackground.src})`,
backgroundPosition: 'center',
backgroundSize: '100% 100%',
padding: '1.5rem',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: secondaryFontColor,
fontSize: '3.5rem',
paddingBottom: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: 'auto',
textAlign: 'center',
}}
>
{errorMessage}
</div>
</div>
),
{
width: openGraphImageWidth,
height: openGraphImageHeight,
fonts: [
{
name: 'Typewriter',
data: fontData,
style: 'normal',
},
],
},
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -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;

View File

@@ -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`,
},
});

View File

@@ -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));
}

View File

@@ -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());
}

View File

@@ -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',
}

View File

@@ -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<boolean> {
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
*/

View File

@@ -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));
}
}