mirror of
https://github.com/placeholder-soft/web.git
synced 2026-01-12 22:45:00 +08:00
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:
committed by
GitHub
parent
c6b98ce3ac
commit
83bc4f642b
38
apps/web/app/(base-org)/frames/names/page.tsx
Normal file
38
apps/web/app/(base-org)/frames/names/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
21
apps/web/pages/api/basenames/[name]/isNameAvailable.ts
Normal file
21
apps/web/pages/api/basenames/[name]/isNameAvailable.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
15
apps/web/pages/api/basenames/frame/01_inputSearchValue.ts
Normal file
15
apps/web/pages/api/basenames/frame/01_inputSearchValue.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
64
apps/web/pages/api/basenames/frame/03_getPriceAndConfirm.ts
Normal file
64
apps/web/pages/api/basenames/frame/03_getPriceAndConfirm.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
45
apps/web/pages/api/basenames/frame/04_txSuccess.ts
Normal file
45
apps/web/pages/api/basenames/frame/04_txSuccess.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
BIN
apps/web/pages/api/basenames/frame/assets/initial-image.png
Normal file
BIN
apps/web/pages/api/basenames/frame/assets/initial-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
BIN
apps/web/pages/api/basenames/frame/assets/registration-bg.png
Normal file
BIN
apps/web/pages/api/basenames/frame/assets/registration-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
BIN
apps/web/pages/api/basenames/frame/assets/retry-search-image.png
Normal file
BIN
apps/web/pages/api/basenames/frame/assets/retry-search-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
BIN
apps/web/pages/api/basenames/frame/assets/search-image.png
Normal file
BIN
apps/web/pages/api/basenames/frame/assets/search-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
4
apps/web/pages/api/basenames/frame/constants.ts
Normal file
4
apps/web/pages/api/basenames/frame/constants.ts
Normal 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;
|
||||
123
apps/web/pages/api/basenames/frame/frameResponses.ts
Normal file
123
apps/web/pages/api/basenames/frame/frameResponses.ts
Normal 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`,
|
||||
},
|
||||
});
|
||||
170
apps/web/pages/api/basenames/frame/tx.ts
Normal file
170
apps/web/pages/api/basenames/frame/tx.ts
Normal 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));
|
||||
}
|
||||
10
apps/web/src/utils/formatWei.ts
Normal file
10
apps/web/src/utils/formatWei.ts
Normal 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());
|
||||
}
|
||||
8
apps/web/src/utils/frames/basenames.ts
Normal file
8
apps/web/src/utils/frames/basenames.ts
Normal 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',
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
13
apps/web/src/utils/weiToEth.ts
Normal file
13
apps/web/src/utils/weiToEth.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user