Skip to content

Commit

Permalink
Feat/basenames frame (#862)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
brendan-defi authored Aug 22, 2024
1 parent e45d1e5 commit a172475
Show file tree
Hide file tree
Showing 20 changed files with 937 additions and 3 deletions.
38 changes: 38 additions & 0 deletions apps/web/app/(base-org)/frames/names/page.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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 changes: 21 additions & 0 deletions apps/web/pages/api/basenames/[name]/isNameAvailable.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions apps/web/pages/api/basenames/frame/01_inputSearchValue.ts
Original file line number Diff line number Diff line change
@@ -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' });
}
}
Original file line number Diff line number Diff line change
@@ -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 changes: 64 additions & 0 deletions apps/web/pages/api/basenames/frame/03_getPriceAndConfirm.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 45 additions & 0 deletions apps/web/pages/api/basenames/frame/04_txSuccess.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit a172475

Please sign in to comment.