diff --git a/apps/base-docs/assets/images/onchainkit-tutorials/inspect-ock-theme.gif b/apps/base-docs/assets/images/onchainkit-tutorials/inspect-ock-theme.gif new file mode 100644 index 0000000000..bb776971fc Binary files /dev/null and b/apps/base-docs/assets/images/onchainkit-tutorials/inspect-ock-theme.gif differ diff --git a/apps/base-docs/assets/images/onchainkit-tutorials/swapped-theme-before.png b/apps/base-docs/assets/images/onchainkit-tutorials/swapped-theme-before.png new file mode 100644 index 0000000000..39599d6680 Binary files /dev/null and b/apps/base-docs/assets/images/onchainkit-tutorials/swapped-theme-before.png differ diff --git a/apps/base-docs/assets/images/onchainkit-tutorials/swapped-theme-final.png b/apps/base-docs/assets/images/onchainkit-tutorials/swapped-theme-final.png new file mode 100644 index 0000000000..e38455e417 Binary files /dev/null and b/apps/base-docs/assets/images/onchainkit-tutorials/swapped-theme-final.png differ diff --git a/apps/base-docs/tutorials/docs/1_ock-swap-theme.md b/apps/base-docs/tutorials/docs/1_ock-swap-theme.md new file mode 100644 index 0000000000..322975664c --- /dev/null +++ b/apps/base-docs/tutorials/docs/1_ock-swap-theme.md @@ -0,0 +1,198 @@ +--- +title: 'Create a Custom Themed Swap Component with OnchainKit' +slug: /create-custom-themed-swap-component +description: Learn how to implement a swap component with a custom theme using OnchainKit in your React application. +author: hughescoin +keywords: [OnchainKit, Swap Component, Custom Theme, React, TypeScript, ERC20 Tokens, Base Chain] +tags: ['frontend', 'defi', 'ethereum', 'base'] +difficulty: medium +displayed_sidebar: null +--- + +In this tutorial, you'll learn how to create a swap component with a custom theme using OnchainKit. We'll start with the OnchainKit App Template and modify it to include a swap interface for ERC20 tokens on the Base. + +--- + +## Objectives + +By the end of this tutorial, you should be able to: + +- Set up a project using the OnchainKit App Template +- Implement a swap component for ERC20 tokens +- Customize the theme of your OnchainKit components +- Apply CSS overrides to fine-tune the appearance of your app + +## Prerequisites + +### React and TypeScript + +You should be familiar with React and TypeScript. If you're new to these technologies, consider reviewing their [official documentation](https://react.dev/) first. + +### OnchainKit + +This tutorial uses OnchainKit. Familiarity with its basic concepts will be helpful. + +--- + +## Setting up the Project + +To get started, clone the OnchainKit App Template by running: + +```bash +git clone git@github.com:coinbase/onchain-app-template.git +``` + +If you have an existing app that uses OnchainKit, update to the latest version: + +```bash +bun update @coinbase/onchainkit --latest +``` + +Now let's implement the Swap component by importing it from OnchainKit. Import it into a new route of your app or, if you're following along, the `src/app/page.tsx` file: + +```ts +import { + Swap, + SwapAmountInput, + SwapToggleButton, + SwapButton, + SwapMessage, + SwapToast, +} from '@coinbase/onchainkit/swap'; + +import type { Token } from 'node_modules/@coinbase/onchainkit/esm/token/types'; +``` + +The `` component enables you to swap any ERC20 token on Base. For this example, your users will be able to swap between USDC and ETH. Next, using the `Token` type, create instances of ETH and USDC. + +```ts +const ETHToken: Token = { + address: '', + chainId: 8453, + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + image: + 'https://dynamic-assets.coinbase.com/dbb4b4983bde81309ddab83eb598358eb44375b930b94687ebe38bc22e52c3b2125258ffb8477a5ef22e33d6bd72e32a506c391caa13af64c00e46613c3e5806/asset_icons/4113b082d21cc5fab17fc8f2d19fb996165bcce635e6900f7fc2d57c4ef33ae9.png', +}; + +const USDCToken: Token = { + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + chainId: 8453, + decimals: 6, + name: 'USDC', + symbol: 'USDC', + image: + 'https://dynamic-assets.coinbase.com/3c15df5e2ac7d4abbe9499ed9335041f00c620f28e8de2f93474a9f432058742cdf4674bd43f309e69778a26969372310135be97eb183d91c492154176d455b8/asset_icons/9d67b728b6c8f457717154b3a35f9ddc702eae7e76c4684ee39302c4d7fd0bb8.png', +}; +``` + +![swap-component-default](../../assets/images/onchainkit-tutorials/swapped-theme-before.png) + +Here's a [sample](https://gist.github.com/hughescoin/4558feabb4f40b51f800091f04a945ae) of the full `page.tsx` file for reference. + +## Changing the Theme + +To change the theme of the site, navigate to `src/components/OnchainProviders.tsx` and add a `config` object to the `OnchainKitProvider`. This is the first step in enabling Themes for your project. + +```js +config={{ + appearance: { + mode: 'auto', // 'auto' | 'light' | 'dark' + theme: 'default', // 'default' | 'base' | 'cyberpunk' | 'hacker' + }, +}} +``` + +OnchainKit provides you with four preset themes. We'll use the "hacker" theme for its fonts but change the colors to a different palette. + +If you need help coming up with a color palette, there are many online tools available. For example, I used [color magic](https://colormagic.app). + +## Customizing the CSS + +Once you've chosen your colors, add them to the CSS file. Update the `src/app/global.css` file to include CSS overrides for OnchainKit properties. In the sample below, the comments provide guidance on which properties will change color. + +:::tip Having trouble changing an element? + +Use the "Inspect" feature in your browser to identify the element you wish to override in your `global.css` file. + +![Inspect Element GIF](../../assets/images/onchainkit-tutorials/inspect-ock-theme.gif) + +::: + +```css +@layer base { + :root, + .default-light, + .default-dark, + .base, + .cyberpunk, + .hacker { + /* Text colors */ + --ock-text-inverse: #f1d579; + --ock-text-foreground: #8c3e21; + --ock-text-foreground-muted: #f1d579; + --ock-text-error: #c85c2d; + --ock-text-primary: #e1a04c; + --ock-text-success: #f5b370; + --ock-text-warning: #f1d579; + --ock-text-disabled: #8c3e21; + + /* Background colors */ + --ock-bg-default: #8c3e21; + --ock-bg-default-hover: #c85c2d; + --ock-bg-default-active: #f1d579; + --ock-bg-alternate: #f1d579; + --ock-bg-alternate-hover: #e1a04c; + --ock-bg-alternate-active: #c85c2d; + --ock-bg-inverse: #c85c2d; + --ock-bg-inverse-hover: #e1a04c; + --ock-bg-inverse-active: #f5b370; + --ock-bg-primary: #c85c2d; + --ock-bg-primary-hover: #e1a04c; + --ock-bg-primary-active: #f1d579; + --ock-bg-primary-washed: #f5b370; + --ock-bg-primary-disabled: #8c3e21; + --ock-bg-secondary: #e1a04c; + --ock-bg-secondary-hover: #f1d579; + --ock-bg-secondary-active: #f5b370; + --ock-bg-error: #c85c2d; + --ock-bg-warning: #f1d579; + --ock-bg-success: #f5b370; + --ock-bg-default-reverse: #c85c2d; + + /* Icon colors */ + --ock-icon-color-primary: #c85c2d; + --ock-icon-color-foreground: #c85c2d; + --ock-icon-color-foreground-muted: #e1a04c; + --ock-icon-color-inverse: #f5b370; + --ock-icon-color-error: #c85c2d; + --ock-icon-color-success: #f5b370; + --ock-icon-color-warning: #f1d579; + + /* Line colors */ + --ock-line-primary: #c85c2d; + --ock-line-default: #8c3e21; + --ock-line-heavy: #f1d579; + --ock-line-inverse: #e1a04c; + } +} + +.ock-font-family.font-semibold.text-xl.leading-7 { + color: #f1d579; +} +``` + +Now refresh your page, and you should see your swap component change to your defined color palette! + +![swap-component](../../assets/images/onchainkit-tutorials/swapped-theme-final.png) + +If something looks off, remember to check that you've overridden the correct element. See the above tip to learn how to find the correct element. + +## Conclusion + +Congratulations! You've successfully implemented the `` component and customized it to a theme of your choice. Pretty neat, right? + +[OnchainKit App Template]: https://github.com/coinbase/onchain-app-template +[color magic]: https://colormagic.app +[sample]: https://gist.github.com/hughescoin/4558feabb4f40b51f800091f04a945ae diff --git a/apps/web/app/(basenames)/names/RegistrationProviders.tsx b/apps/web/app/(basenames)/names/RegistrationProviders.tsx index cb77e17fb3..070d7c5882 100644 --- a/apps/web/app/(basenames)/names/RegistrationProviders.tsx +++ b/apps/web/app/(basenames)/names/RegistrationProviders.tsx @@ -5,10 +5,16 @@ import RegistrationProvider from 'apps/web/src/components/Basenames/Registration const usernameRegistrationAnalyticContext = 'username_registration'; -export default function RegistrationProviders({ children }: { children: React.ReactNode }) { +export default function RegistrationProviders({ + children, + code, +}: { + children: React.ReactNode; + code?: string; +}) { return ( - {children} + {children} ); } diff --git a/apps/web/app/(basenames)/names/page.tsx b/apps/web/app/(basenames)/names/page.tsx index 738fdd54c4..52787ef265 100644 --- a/apps/web/app/(basenames)/names/page.tsx +++ b/apps/web/app/(basenames)/names/page.tsx @@ -5,7 +5,6 @@ import RegistrationFAQ from 'apps/web/src/components/Basenames/RegistrationFaq'; import RegistrationFlow from 'apps/web/src/components/Basenames/RegistrationFlow'; import RegistrationValueProp from 'apps/web/src/components/Basenames/RegistrationValueProp'; import type { Metadata } from 'next'; -import { Suspense } from 'react'; import basenameCover from './basename_cover.png'; import { initialFrame } from 'apps/web/pages/api/basenames/frame/frameResponses'; @@ -28,18 +27,19 @@ export const metadata: Metadata = { }, }; -export default async function Page() { +type PageProps = { searchParams?: { code?: string } }; +export default async function Page({ searchParams }: PageProps) { + const code = searchParams?.code; + return ( - - -
- - - - -
-
+ +
+ + + + +
); diff --git a/apps/web/pages/api/proofs/discountCode/consume/index.ts b/apps/web/pages/api/proofs/discountCode/consume/index.ts new file mode 100644 index 0000000000..5148042a4e --- /dev/null +++ b/apps/web/pages/api/proofs/discountCode/consume/index.ts @@ -0,0 +1,36 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { logger } from 'apps/web/src/utils/logger'; +import { withTimeout } from 'apps/web/pages/api/decorators'; +import { incrementDiscountCodeUsage } from 'apps/web/src/utils/proofs/discount_code_storage'; + +/* + this endpoint will increment the discount code usage to prevent abuse +*/ + +type DiscountCodeRequest = { + code: string; +}; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const { code } = req.body as DiscountCodeRequest; + + if (!code || typeof code !== 'string') { + return res.status(500).json({ error: 'Invalid request' }); + } + + await incrementDiscountCodeUsage(code); + + return res.status(200).json({ success: true }); + } catch (error: unknown) { + logger.error('error incrementing the discount code', error); + } + // If error is not an instance of Error, return a generic error message + return res.status(500).json({ error: 'An unexpected error occurred' }); +} + +export default withTimeout(handler); diff --git a/apps/web/pages/api/proofs/discountCode/index.ts b/apps/web/pages/api/proofs/discountCode/index.ts new file mode 100644 index 0000000000..0c2098dbf8 --- /dev/null +++ b/apps/web/pages/api/proofs/discountCode/index.ts @@ -0,0 +1,81 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { proofValidation, signDiscountMessageWithTrustedSigner } from 'apps/web/src/utils/proofs'; +import { logger } from 'apps/web/src/utils/logger'; +import { withTimeout } from 'apps/web/pages/api/decorators'; +import { Address, Hash, stringToHex } from 'viem'; +import { USERNAME_DISCOUNT_CODE_VALIDATORS } from 'apps/web/src/addresses/usernames'; +import { getDiscountCode } from 'apps/web/src/utils/proofs/discount_code_storage'; + +export type DiscountCodeResponse = { + discountValidatorAddress: Address; + address: Address; + signedMessage: Hash; +}; + +/* +this endpoint returns whether or a discount code is valid +*/ +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'method not allowed' }); + } + const { address, chain, code } = req.query; + const validationErr = proofValidation(address, chain); + if (validationErr) { + return res.status(validationErr.status).json({ error: validationErr.error }); + } + + const chainId = parseInt(chain as string); + + if (!code || typeof code !== 'string') { + return res.status(500).json({ error: 'Discount code invalid' }); + } + + try { + // 1. get the database model + const discountCodes = await getDiscountCode(code); + + // 2. Validation: Coupon exists + if (!discountCodes || discountCodes.length === 0) { + return res.status(500).json({ error: 'Discount code invalid' }); + } + + const discountCode = discountCodes[0]; + + // 2.1 Validation: Coupon is expired + if (new Date(discountCode.expires_at) < new Date()) { + return res.status(500).json({ error: 'Discount code invalid' }); + } + + // 2.2 Validation: Coupon can be redeemed + if (Number(discountCode.usage_count) >= Number(discountCode.usage_limit)) { + return res.status(500).json({ error: 'Discount code invalid' }); + } + + // 3. Sign the validationData + const couponCodeUuid = stringToHex(discountCode.code, { size: 32 }); + const expirationTimeUnix = Math.floor(discountCode.expires_at.getTime() / 1000); + + const signature = await signDiscountMessageWithTrustedSigner( + address as Address, + couponCodeUuid, + USERNAME_DISCOUNT_CODE_VALIDATORS[chainId], + expirationTimeUnix, + ); + + // 4. Return the discount data + const result: DiscountCodeResponse = { + discountValidatorAddress: USERNAME_DISCOUNT_CODE_VALIDATORS[chainId], + address: address as Address, + signedMessage: signature, + }; + + return res.status(200).json(result); + } catch (error: unknown) { + logger.error('error getting proofs for discount code', error); + } + // If error is not an instance of Error, return a generic error message + return res.status(500).json({ error: 'An unexpected error occurred' }); +} + +export default withTimeout(handler); diff --git a/apps/web/public/images/partners/beliefs.svg b/apps/web/public/images/partners/beliefs.svg new file mode 100644 index 0000000000..e06c0a3a96 --- /dev/null +++ b/apps/web/public/images/partners/beliefs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/addresses/usernames.ts b/apps/web/src/addresses/usernames.ts index 6755eb0ded..99bccbdef7 100644 --- a/apps/web/src/addresses/usernames.ts +++ b/apps/web/src/addresses/usernames.ts @@ -86,3 +86,8 @@ export const EXPONENTIAL_PREMIUM_PRICE_ORACLE: AddressMap = { [baseSepolia.id]: '0x2B73408052825e17e0Fe464f92De85e8c7723231', [base.id]: '0xd53B558e1F07289acedf028d226974AbBa258312', }; + +export const USERNAME_DISCOUNT_CODE_VALIDATORS: AddressMap = { + [baseSepolia.id]: '0x52acEeB464F600437a3681bEC087fb53F3f75638', + [base.id]: '0x6F9A31238F502E9C9489274E59a44c967F4deC91', +}; diff --git a/apps/web/src/components/Basenames/RegistrationContext.tsx b/apps/web/src/components/Basenames/RegistrationContext.tsx index ff958c9deb..c3153ce1b9 100644 --- a/apps/web/src/components/Basenames/RegistrationContext.tsx +++ b/apps/web/src/components/Basenames/RegistrationContext.tsx @@ -1,4 +1,6 @@ 'use client'; +import RegistrarControllerABI from 'apps/web/src/abis/RegistrarControllerABI'; +import { USERNAME_REGISTRAR_CONTROLLER_ADDRESSES } from 'apps/web/src/addresses/usernames'; import { useAnalytics } from 'apps/web/contexts/Analytics'; import { useErrors } from 'apps/web/contexts/Errors'; import { @@ -6,8 +8,8 @@ import { findFirstValidDiscount, useAggregatedDiscountValidators, } from 'apps/web/src/hooks/useAggregatedDiscountValidators'; -import useBaseEnsName from 'apps/web/src/hooks/useBaseEnsName'; import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; +import { useRegisterNameCallback } from 'apps/web/src/hooks/useRegisterNameCallback'; import { Discount, formatBaseEthDomain, isValidDiscount } from 'apps/web/src/utils/usernames'; import { ActionType } from 'libs/base-ui/utils/logEvent'; import { useRouter } from 'next/navigation'; @@ -20,12 +22,20 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; -import { useInterval } from 'usehooks-ts'; -import { Address, TransactionReceipt } from 'viem'; import { base } from 'viem/chains'; -import { useAccount, useWaitForTransactionReceipt } from 'wagmi'; +import { useAccount, useReadContract } from 'wagmi'; +import { zeroAddress } from 'viem'; +import { + useDiscountedNameRegistrationPrice, + useNameRegistrationPrice, +} from 'apps/web/src/hooks/useNameRegistrationPrice'; +import { BatchCallsStatus } from 'apps/web/src/hooks/useWriteContractsWithLogs'; +import { WriteTransactionWithReceiptStatus } from 'apps/web/src/hooks/useWriteContractWithReceipt'; +import useBaseEnsName from 'apps/web/src/hooks/useBaseEnsName'; +import { BaseName } from '@coinbase/onchainkit/identity'; export enum RegistrationSteps { Search = 'search', @@ -44,14 +54,20 @@ export type RegistrationContextProps = { setRegistrationStep: Dispatch>; selectedName: string; setSelectedName: Dispatch>; - registerNameTransactionHash: `0x${string}` | undefined; - setRegisterNameTransactionHash: Dispatch>; + selectedNameFormatted: BaseName; + years: number; + setYears: Dispatch>; redirectToProfile: () => void; loadingDiscounts: boolean; discount: DiscountData | undefined; allActiveDiscounts: Set; - transactionData: TransactionReceipt | undefined; - transactionError: unknown | null; + reverseRecord: boolean; + setReverseRecord: Dispatch>; + hasExistingBasename: boolean; + registerNameIsPending: boolean; + registerNameError: unknown; + registerName: () => Promise; + code: string | undefined; }; export const RegistrationContext = createContext({ @@ -59,6 +75,7 @@ export const RegistrationContext = createContext({ searchInputHovered: false, registrationStep: RegistrationSteps.Search, selectedName: '', + selectedNameFormatted: '.base.eth', setSearchInputFocused: function () { return undefined; }, @@ -71,28 +88,33 @@ export const RegistrationContext = createContext({ setSelectedName: function () { return undefined; }, - registerNameTransactionHash: '0x', - setRegisterNameTransactionHash: function () { + redirectToProfile: function () { return undefined; }, - redirectToProfile: function () { + years: 1, + setYears: function () { return undefined; }, loadingDiscounts: true, discount: undefined, allActiveDiscounts: new Set(), - transactionData: undefined, - transactionError: null, + registerName: function () { + return undefined; + }, }); type RegistrationProviderProps = { children?: ReactNode; + code?: string; }; // Maybe not the best place for this export const registrationTransitionDuration = 'duration-700'; -export default function RegistrationProvider({ children }: RegistrationProviderProps) { +export default function RegistrationProvider({ children, code }: RegistrationProviderProps) { + // Wallet + const { address } = useAccount(); + // UI state const [searchInputFocused, setSearchInputFocused] = useState(false); const [searchInputHovered, setSearchInputHovered] = useState(false); @@ -101,28 +123,31 @@ export default function RegistrationProvider({ children }: RegistrationProviderP RegistrationSteps.Search, ); + // If user has a basename, reverse record is set to false + const { refetch: refetchBaseEnsName } = useBaseEnsName({ + address, + }); + useEffect(() => { window.scrollTo(0, 0); }, [registrationStep]); const { basenameChain } = useBasenameChain(); - const router = useRouter(); // Analytics const { logEventWithContext } = useAnalytics(); const { logError } = useErrors(); - // Web3 data - const { address } = useAccount(); - const { data: currentAddressName, refetch: baseEnsNameRefetch } = useBaseEnsName({ - address, - }); - // Username discount states - const { data: discounts, loading: loadingDiscounts } = useAggregatedDiscountValidators(); + const { data: discounts, loading: loadingDiscounts } = useAggregatedDiscountValidators(code); const discount = findFirstValidDiscount(discounts); + const selectedNameFormatted = useMemo( + () => formatBaseEthDomain(selectedName, basenameChain.id), + [basenameChain.id, selectedName], + ); + const allActiveDiscounts = useMemo( () => new Set( @@ -133,88 +158,124 @@ export default function RegistrationProvider({ children }: RegistrationProviderP [discounts], ); - // TODO: Not a big fan of this, I think ideally we'd have useRegisterNameCallback here - const [registerNameTransactionHash, setRegisterNameTransactionHash] = useState< - Address | undefined - >(); - - // Wait for text record transaction to be processed - const { - data: transactionData, - isFetching: transactionIsFetching, - isSuccess: transactionIsSuccess, - error: transactionError, - } = useWaitForTransactionReceipt({ - hash: registerNameTransactionHash, - chainId: basenameChain.id, - query: { - enabled: !!registerNameTransactionHash, - }, - }); - - useInterval(() => { - if (registrationStep !== RegistrationSteps.Pending) { - return; - } - baseEnsNameRefetch() - .then(() => { - const [extractedName] = (currentAddressName ?? '').split('.'); - if (extractedName === selectedName && registrationStep === RegistrationSteps.Pending) { - setRegistrationStep(RegistrationSteps.Success); - } - }) - .catch((error) => { - logError(error, 'Failed to refetch basename'); - }); - }, 1500); - const profilePath = useMemo(() => { if (basenameChain.id === base.id) { return `name/${selectedName}`; } else { - return `name/${formatBaseEthDomain(selectedName, basenameChain.id)}`; + return `name/${selectedNameFormatted}`; } - }, [basenameChain.id, selectedName]); + }, [basenameChain.id, selectedName, selectedNameFormatted]); - const redirectToProfile = useCallback(() => { + const redirectToProfile = useCallback(async () => { router.push(profilePath); }, [profilePath, router]); + const [years, setYears] = useState(1); + + // Has already registered with discount + const { data: hasRegisteredWithDiscount } = useReadContract({ + abi: RegistrarControllerABI, + address: USERNAME_REGISTRAR_CONTROLLER_ADDRESSES[basenameChain.id], + functionName: 'discountedRegistrants', + args: [address ?? zeroAddress], + }); + + const { data: discountedPrice } = useDiscountedNameRegistrationPrice( + selectedName, + years, + discount?.discountKey, + ); + + const { data: initialPrice } = useNameRegistrationPrice(selectedName, years); + + const price = hasRegisteredWithDiscount ? initialPrice : discountedPrice ?? initialPrice; + + // Registration time + const { + callback: registerName, + isPending: registerNameIsPending, + error: registerNameError, + reverseRecord, + setReverseRecord, + hasExistingBasename, + batchCallsStatus, + registerNameStatus, + } = useRegisterNameCallback( + selectedName, + price, + years, + hasRegisteredWithDiscount ? undefined : discount?.discountKey, + hasRegisteredWithDiscount ? undefined : discount?.validationData, + ); + + // Move from search to claim useEffect(() => { - if (transactionIsFetching && registrationStep === RegistrationSteps.Claim) { - logEventWithContext('register_name_transaction_processing', ActionType.change); + if (registrationStep === RegistrationSteps.Search && selectedName.length) { + setRegistrationStep(RegistrationSteps.Claim); + } + }, [registrationStep, selectedName.length]); + + // transaction with paymaster + useEffect(() => { + if (batchCallsStatus === BatchCallsStatus.Approved) { setRegistrationStep(RegistrationSteps.Pending); } + if (batchCallsStatus === BatchCallsStatus.Success) { + setRegistrationStep(RegistrationSteps.Success); + } + }, [batchCallsStatus, setRegistrationStep]); - if (transactionIsSuccess && registrationStep === RegistrationSteps.Pending) { - if (transactionData.status === 'success') { - logEventWithContext('register_name_transaction_success', ActionType.change); - setRegistrationStep(RegistrationSteps.Success); - router.prefetch(profilePath); - } + // transaction without paymaster + useEffect(() => { + if (registerNameStatus === WriteTransactionWithReceiptStatus.Approved) { + setRegistrationStep(RegistrationSteps.Pending); + } - if (transactionData.status === 'reverted') { - logEventWithContext('register_name_transaction_reverted', ActionType.change, { - error: `Transaction reverted: ${transactionData.transactionHash}`, - }); - } + if (registerNameStatus === WriteTransactionWithReceiptStatus.Success) { + setRegistrationStep(RegistrationSteps.Success); } - }, [ - baseEnsNameRefetch, - logEventWithContext, - profilePath, - registrationStep, - router, - transactionData, - transactionIsFetching, - transactionIsSuccess, - ]); + }, [registerNameStatus, setRegistrationStep]); + // Refetch name on success useEffect(() => { - if (selectedName.length) { - setRegistrationStep(RegistrationSteps.Claim); + if (registrationStep === RegistrationSteps.Success) { + refetchBaseEnsName().catch((error) => logError(error, 'Failed to refetch Basename')); + router.prefetch(profilePath); } - }, [selectedName.length]); + }, [logError, profilePath, refetchBaseEnsName, registrationStep, router]); + + // On registration success with discount code: mark as consumed + const hasRun = useRef(false); + + useEffect(() => { + const consumeDiscountCode = async () => { + if ( + !hasRun.current && + registrationStep === RegistrationSteps.Success && + code && + discount && + discount.discount === Discount.DISCOUNT_CODE + ) { + hasRun.current = true; + const response = await fetch('/api/proofs/discountCode/consume', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code }), + }); + + if (!response.ok) { + throw new Error('Failed to record discount code consumption'); + } + } + }; + + consumeDiscountCode().catch((error) => { + logError(error, 'Error recording discount code consumption'); + hasRun.current = false; + }); + }, [discount, code, registrationStep, logError]); // Log user moving through the flow useEffect(() => { @@ -227,13 +288,6 @@ export default function RegistrationProvider({ children }: RegistrationProviderP logEventWithContext('selected_name', ActionType.change); }, [logEventWithContext, selectedName]); - // Log error - useEffect(() => { - if (transactionError) { - logError(transactionError, 'Failed to fetch the transaction receipt'); - } - }, [logError, transactionError]); - const values = useMemo(() => { return { searchInputFocused, @@ -241,30 +295,42 @@ export default function RegistrationProvider({ children }: RegistrationProviderP setSearchInputFocused, setSearchInputHovered, selectedName, + selectedNameFormatted, setSelectedName, registrationStep, setRegistrationStep, - registerNameTransactionHash, - setRegisterNameTransactionHash, redirectToProfile, loadingDiscounts, discount, allActiveDiscounts, - transactionData, - transactionError, + years, + setYears, + reverseRecord, + setReverseRecord, + hasExistingBasename, + registerNameIsPending, + registerNameError, + registerName, + code, }; }, [ - allActiveDiscounts, - discount, - loadingDiscounts, - redirectToProfile, - registerNameTransactionHash, - registrationStep, searchInputFocused, searchInputHovered, selectedName, - transactionData, - transactionError, + selectedNameFormatted, + registrationStep, + redirectToProfile, + loadingDiscounts, + discount, + allActiveDiscounts, + years, + reverseRecord, + setReverseRecord, + hasExistingBasename, + registerNameIsPending, + registerNameError, + registerName, + code, ]); return {children}; diff --git a/apps/web/src/components/Basenames/RegistrationForm/index.tsx b/apps/web/src/components/Basenames/RegistrationForm/index.tsx index f9d24bdc29..7267a9d4fa 100644 --- a/apps/web/src/components/Basenames/RegistrationForm/index.tsx +++ b/apps/web/src/components/Basenames/RegistrationForm/index.tsx @@ -17,7 +17,6 @@ import { Icon } from 'apps/web/src/components/Icon/Icon'; import Label from 'apps/web/src/components/Label'; import Tooltip from 'apps/web/src/components/Tooltip'; import TransactionError from 'apps/web/src/components/TransactionError'; -import TransactionStatus from 'apps/web/src/components/TransactionStatus'; import { usePremiumEndDurationRemaining } from 'apps/web/src/hooks/useActiveEthPremiumAmount'; import useBasenameChain, { supportedChainIds } from 'apps/web/src/hooks/useBasenameChain'; import { useEthPriceFromUniswap } from 'apps/web/src/hooks/useEthPriceFromUniswap'; @@ -25,12 +24,11 @@ import { useDiscountedNameRegistrationPrice, useNameRegistrationPrice, } from 'apps/web/src/hooks/useNameRegistrationPrice'; -import { useRegisterNameCallback } from 'apps/web/src/hooks/useRegisterNameCallback'; import { useRentPrice } from 'apps/web/src/hooks/useRentPrice'; import { formatBaseEthDomain, IS_EARLY_ACCESS } from 'apps/web/src/utils/usernames'; import classNames from 'classnames'; import { ActionType } from 'libs/base-ui/utils/logEvent'; -import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { ChangeEvent, useCallback, useMemo, useState } from 'react'; import { formatEther, zeroAddress } from 'viem'; import { useAccount, useBalance, useReadContract, useSwitchChain } from 'wagmi'; @@ -72,13 +70,18 @@ export default function RegistrationForm() { ); const { - transactionData, - transactionError, selectedName, - setRegisterNameTransactionHash, discount, + years, + setYears, + reverseRecord, + setReverseRecord, + hasExistingBasename, + registerName, + registerNameError, + registerNameIsPending, + code, } = useRegistration(); - const [years, setYears] = useState(1); const [premiumExplainerModalOpen, setPremiumExplainerModalOpen] = useState(false); const togglePremiumExplainerModal = useCallback(() => { @@ -96,13 +99,13 @@ export default function RegistrationForm() { logEventWithContext('registration_form_increment_year', ActionType.click); setYears((n) => n + 1); - }, [logEventWithContext]); + }, [logEventWithContext, setYears]); const decrement = useCallback(() => { logEventWithContext('registration_form_decement_year', ActionType.click); setYears((n) => (n > 1 ? n - 1 : n)); - }, [logEventWithContext]); + }, [logEventWithContext, setYears]); const ethUsdPrice = useEthPriceFromUniswap(); const { data: initialPrice } = useNameRegistrationPrice(selectedName, years); @@ -130,29 +133,6 @@ export default function RegistrationForm() { const price = hasRegisteredWithDiscount ? initialPrice : discountedPrice ?? initialPrice; - const { - callback: registerName, - data: registerNameTransactionHash, - isPending: registerNameTransactionIsPending, - error: registerNameError, - reverseRecord, - setReverseRecord, - hasExistingBasename, - } = useRegisterNameCallback( - selectedName, - price, - years, - hasRegisteredWithDiscount ? undefined : discount?.discountKey, - hasRegisteredWithDiscount ? undefined : discount?.validationData, - ); - - useEffect(() => { - if (registerNameTransactionHash) { - logEventWithContext('register_name_transaction_approved', ActionType.change); - } - if (registerNameTransactionHash) setRegisterNameTransactionHash(registerNameTransactionHash); - }, [logEventWithContext, registerNameTransactionHash, setRegisterNameTransactionHash]); - const registerNameCallback = useCallback(() => { registerName().catch((error) => { logError(error, 'Failed to register name'); @@ -179,7 +159,7 @@ export default function RegistrationForm() { const isPremiumActive = premiumPrice && premiumPrice !== 0n && seconds !== 0n; const mainRegistrationElementClasses = classNames( - 'z-10 flex flex-col items-start justify-between gap-6 bg-[#F7F7F7] p-8 text-gray-60 shadow-xl md:flex-row md:items-center', + 'z-10 flex flex-col items-start justify-between gap-6 bg-[#F7F7F7] p-8 text-gray-60 shadow-xl md:flex-row md:items-center relative z-20', { 'rounded-2xl': !isPremiumActive, 'rounded-b-2xl': isPremiumActive, @@ -337,10 +317,9 @@ export default function RegistrationForm() { variant={ButtonVariants.Black} size={ButtonSizes.Medium} disabled={ - insufficientBalanceToRegisterAndCorrectChain || - registerNameTransactionIsPending + insufficientBalanceToRegisterAndCorrectChain || registerNameIsPending } - isLoading={registerNameTransactionIsPending} + isLoading={registerNameIsPending} rounded fullWidth > @@ -351,20 +330,17 @@ export default function RegistrationForm() { - - {transactionError !== null && ( - + {code && ( +
+ Claim your free creator basename — See you this{' '} + friday for dinner +
)} + {registerNameError && ( )} - {transactionData && transactionData.status === 'reverted' && ( - - )} + {!IS_EARLY_ACCESS && (

diff --git a/apps/web/src/components/Basenames/RegistrationProfileForm/index.tsx b/apps/web/src/components/Basenames/RegistrationProfileForm/index.tsx index 9a2d71a8ee..5af3e7d881 100644 --- a/apps/web/src/components/Basenames/RegistrationProfileForm/index.tsx +++ b/apps/web/src/components/Basenames/RegistrationProfileForm/index.tsx @@ -12,7 +12,6 @@ import Fieldset from 'apps/web/src/components/Fieldset'; import { Icon } from 'apps/web/src/components/Icon/Icon'; import Label from 'apps/web/src/components/Label'; import TransactionError from 'apps/web/src/components/TransactionError'; -import useBaseEnsName from 'apps/web/src/hooks/useBaseEnsName'; import useWriteBaseEnsTextRecords from 'apps/web/src/hooks/useWriteBaseEnsTextRecords'; import { UsernameTextRecordKeys, @@ -33,14 +32,10 @@ export default function RegistrationProfileForm() { const [currentFormStep, setCurrentFormStep] = useState(FormSteps.Description); const [transitionStep, setTransitionStep] = useState(false); const { logError } = useErrors(); - const { redirectToProfile } = useRegistration(); + const { redirectToProfile, selectedNameFormatted } = useRegistration(); const { address } = useAccount(); const { logEventWithContext } = useAnalytics(); - const { data: baseEnsName } = useBaseEnsName({ - address, - }); - const { updateTextRecords, updatedTextRecords, @@ -49,7 +44,7 @@ export default function RegistrationProfileForm() { writeTextRecordsError, } = useWriteBaseEnsTextRecords({ address: address, - username: baseEnsName, + username: selectedNameFormatted, onSuccess: () => { redirectToProfile(); }, @@ -108,37 +103,37 @@ export default function RegistrationProfileForm() { const descriptionLabelChildren = (

-

+

Add Bio
Step 1 of 3 -

+
); const socialsLabelChildren = (
-

+

Add Socials
Step 2 of 3 -

+
); const keywordsLabelChildren = (
-

+

Add areas of expertise
Step 3 of 3 -

+
); diff --git a/apps/web/src/data/ecosystem.json b/apps/web/src/data/ecosystem.json index 47ebbf5da7..82593de8c3 100644 --- a/apps/web/src/data/ecosystem.json +++ b/apps/web/src/data/ecosystem.json @@ -4223,5 +4223,14 @@ "description": "The friendly crypto wallet built for DeFi & NFTs.", "url": "https://phantom.app/", "imageUrl": "/images/partners/phantom.svg" + }, + { + "name": "Beliefs", + "tags": [ + "social" + ], + "description": "Trenches 2.0. Hunt social content, launch and grow community coins. Bulletproof anti-sniping, max. buys, tipdrops like in $DEGEN. Tokenize tweets/casts in one click for free and get 0.5% of supply on UNI.", + "url": "https://beliefs.social/", + "imageUrl": "/images/partners/beliefs.svg" } ] diff --git a/apps/web/src/hooks/useAggregatedDiscountValidators.ts b/apps/web/src/hooks/useAggregatedDiscountValidators.ts index 382d1624f8..15b241d348 100644 --- a/apps/web/src/hooks/useAggregatedDiscountValidators.ts +++ b/apps/web/src/hooks/useAggregatedDiscountValidators.ts @@ -7,6 +7,7 @@ import { useCheckCBIDAttestations, useCheckCoinbaseAttestations, useCheckEAAttestations, + useDiscountCodeAttestations, useSummerPassAttestations, } from 'apps/web/src/hooks/useAttestations'; import { useActiveDiscountValidators } from 'apps/web/src/hooks/useReadActiveDiscountValidators'; @@ -37,7 +38,7 @@ export function findFirstValidDiscount( return sortedDiscounts.find((data) => data?.discountKey) ?? undefined; } -export function useAggregatedDiscountValidators() { +export function useAggregatedDiscountValidators(code?: string) { const { data: activeDiscountValidators, isLoading: loadingActiveDiscounts } = useActiveDiscountValidators(); const { data: CBIDData, loading: loadingCBIDAttestations } = useCheckCBIDAttestations(); @@ -49,6 +50,8 @@ export function useAggregatedDiscountValidators() { const { data: BuildathonData, loading: loadingBuildathon } = useBuildathonAttestations(); const { data: BaseDotEthData, loading: loadingBaseDotEth } = useBaseDotEthAttestations(); const { data: BNSData, loading: loadingBNS } = useBNSAttestations(); + const { data: DiscountCodeData, loading: loadingDiscountCode } = + useDiscountCodeAttestations(code); const loadingDiscounts = loadingCoinbaseAttestations || @@ -59,7 +62,8 @@ export function useAggregatedDiscountValidators() { loadingBuildathon || loadingSummerPass || loadingBaseDotEth || - loadingBNS; + loadingBNS || + loadingDiscountCode; const discountsToAttestationData = useMemo(() => { const discountMapping: MappedDiscountData = {}; @@ -114,6 +118,16 @@ export function useAggregatedDiscountValidators() { if (BNSData && validator.discountValidator === BNSData.discountValidatorAddress) { discountMapping[Discount.BNS_NAME] = { ...BNSData, discountKey: validator.key }; } + + if ( + DiscountCodeData && + validator.discountValidator === DiscountCodeData.discountValidatorAddress + ) { + discountMapping[Discount.DISCOUNT_CODE] = { + ...DiscountCodeData, + discountKey: validator.key, + }; + } }); return discountMapping; @@ -127,6 +141,7 @@ export function useAggregatedDiscountValidators() { SummerPassData, BaseDotEthData, BNSData, + DiscountCodeData, ]); return { diff --git a/apps/web/src/hooks/useAttestations.ts b/apps/web/src/hooks/useAttestations.ts index c92b4ff7bb..a950bc9c5e 100644 --- a/apps/web/src/hooks/useAttestations.ts +++ b/apps/web/src/hooks/useAttestations.ts @@ -1,5 +1,6 @@ import { useErrors } from 'apps/web/contexts/Errors'; import { CoinbaseProofResponse } from 'apps/web/pages/api/proofs/coinbase'; +import { DiscountCodeResponse } from 'apps/web/pages/api/proofs/discountCode'; import AttestationValidatorABI from 'apps/web/src/abis/AttestationValidator'; import CBIDValidatorABI from 'apps/web/src/abis/CBIdDiscountValidator'; import EarlyAccessValidatorABI from 'apps/web/src/abis/EarlyAccessValidator'; @@ -487,3 +488,71 @@ export function useBNSAttestations() { } return { data: null, loading: isLoading, error }; } + +// returns info about Discount Codes attestations +export function useDiscountCodeAttestations(code?: string) { + const { logError } = useErrors(); + const { address } = useAccount(); + const [loading, setLoading] = useState(false); + const [discountCodeResponse, setDiscountCodeResponse] = useState( + null, + ); + + const { basenameChain } = useBasenameChain(); + + useEffect(() => { + async function checkDiscountCode(a: string, c: string) { + try { + setLoading(true); + const params = new URLSearchParams(); + params.append('address', a); + params.append('chain', basenameChain.id.toString()); + params.append('code', c.toString()); + const response = await fetch(`/api/proofs/discountCode?${params}`); + const result = (await response.json()) as DiscountCodeResponse; + if (response.ok) { + setDiscountCodeResponse(result); + } + } catch (error) { + logError(error, 'Error checking Discount code'); + } finally { + setLoading(false); + } + } + + if (address && !IS_EARLY_ACCESS && !!code) { + checkDiscountCode(address, code).catch((error) => { + logError(error, 'Error checking Discount code'); + }); + } + }, [address, basenameChain.id, code, logError]); + + const signature = discountCodeResponse?.signedMessage; + const readContractArgs = useMemo(() => { + if (!address || !signature || !code) { + return {}; + } + + return { + address: discountCodeResponse?.discountValidatorAddress, + abi: AttestationValidatorABI, + functionName: 'isValidDiscountRegistration', + args: [address, signature], + }; + }, [address, code, discountCodeResponse?.discountValidatorAddress, signature]); + + const { data: isValid, isLoading, error } = useReadContract(readContractArgs); + + if (isValid && discountCodeResponse && address && signature) { + return { + data: { + discountValidatorAddress: discountCodeResponse.discountValidatorAddress, + discount: Discount.DISCOUNT_CODE, + validationData: signature, + }, + loading: false, + error: null, + }; + } + return { data: null, loading: loading || isLoading, error }; +} diff --git a/apps/web/src/hooks/useCapabilitiesSafe.ts b/apps/web/src/hooks/useCapabilitiesSafe.ts index 78fe3a3f2e..09f9c419ba 100644 --- a/apps/web/src/hooks/useCapabilitiesSafe.ts +++ b/apps/web/src/hooks/useCapabilitiesSafe.ts @@ -9,15 +9,16 @@ */ import { Chain } from 'viem'; +import { base } from 'viem/chains'; import { useAccount } from 'wagmi'; import { useCapabilities } from 'wagmi/experimental'; export type UseCapabilitiesSafeProps = { - chain: Chain; + chainId?: Chain['id']; }; -export default function useCapabilitiesSafe({ chain }: UseCapabilitiesSafeProps) { - const { connector, isConnected } = useAccount(); +export default function useCapabilitiesSafe({ chainId }: UseCapabilitiesSafeProps) { + const { connector, isConnected, chainId: currentChainId } = useAccount(); // Metamask doesn't support wallet_getCapabilities const isMetamaskWallet = connector?.id === 'io.metamask'; @@ -25,10 +26,12 @@ export default function useCapabilitiesSafe({ chain }: UseCapabilitiesSafeProps) const { data: capabilities } = useCapabilities({ query: { enabled } }); + const featureChainId = chainId ?? currentChainId ?? base.id; + const atomicBatchEnabled = - (capabilities && capabilities[chain.id]?.atomicBatch?.supported === true) ?? false; + (capabilities && capabilities[featureChainId]?.atomicBatch?.supported === true) ?? false; const paymasterServiceEnabled = - (capabilities && capabilities[chain.id]?.paymasterService?.supported === true) ?? false; + (capabilities && capabilities[featureChainId]?.paymasterService?.supported === true) ?? false; return { atomicBatchEnabled, diff --git a/apps/web/src/hooks/useRegisterNameCallback.ts b/apps/web/src/hooks/useRegisterNameCallback.ts index adc800724f..f812f1b08f 100644 --- a/apps/web/src/hooks/useRegisterNameCallback.ts +++ b/apps/web/src/hooks/useRegisterNameCallback.ts @@ -1,9 +1,11 @@ -import { useAnalytics } from 'apps/web/contexts/Analytics'; import { useErrors } from 'apps/web/contexts/Errors'; import L2ResolverAbi from 'apps/web/src/abis/L2Resolver'; import { USERNAME_L2_RESOLVER_ADDRESSES } from 'apps/web/src/addresses/usernames'; import useBaseEnsName from 'apps/web/src/hooks/useBaseEnsName'; import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; +import useCapabilitiesSafe from 'apps/web/src/hooks/useCapabilitiesSafe'; +import useWriteContractsWithLogs from 'apps/web/src/hooks/useWriteContractsWithLogs'; +import useWriteContractWithReceipt from 'apps/web/src/hooks/useWriteContractWithReceipt'; import { formatBaseEthDomain, IS_EARLY_ACCESS, @@ -11,43 +13,30 @@ import { REGISTER_CONTRACT_ABI, REGISTER_CONTRACT_ADDRESSES, } from 'apps/web/src/utils/usernames'; -import { ActionType } from 'libs/base-ui/utils/logEvent'; -import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { encodeFunctionData, namehash } from 'viem'; -import { useAccount, useSwitchChain, useWriteContract } from 'wagmi'; -import { useCapabilities, useWriteContracts } from 'wagmi/experimental'; +import { useAccount } from 'wagmi'; function secondsInYears(years: number): bigint { const secondsPerYear = 365.25 * 24 * 60 * 60; // .25 accounting for leap years return BigInt(Math.round(years * secondsPerYear)); } -type UseRegisterNameCallbackReturnValue = { - callback: () => Promise; - data: `0x${string}` | undefined; - isPending: boolean; - error: string | undefined | null; - reverseRecord: boolean; - setReverseRecord: Dispatch>; - hasExistingBasename: boolean; -}; - export function useRegisterNameCallback( name: string, value: bigint | undefined, years: number, discountKey?: `0x${string}`, validationData?: `0x${string}`, -): UseRegisterNameCallbackReturnValue { - const { address, chainId, isConnected, connector } = useAccount(); +) { + const { address } = useAccount(); const { basenameChain } = useBasenameChain(); const { logError } = useErrors(); - const { - writeContractsAsync, - isPending: paymasterIsPending, - error: paymasterError, - } = useWriteContracts(); + const { paymasterServiceEnabled } = useCapabilitiesSafe({ + chainId: basenameChain.id, + }); + // If user has a basename, reverse record is set to false const { data: baseEnsName, isLoading: baseEnsNameIsLoading } = useBaseEnsName({ address, }); @@ -59,41 +48,31 @@ export function useRegisterNameCallback( const [reverseRecord, setReverseRecord] = useState(!hasExistingBasename); - const isCoinbaseSmartWallet = connector?.id === 'coinbase'; - const paymasterEnabled = isCoinbaseSmartWallet; + // Transaction with paymaster enabled + const { initiateBatchCalls, batchCallsStatus, batchCallsIsLoading, batchCallsError } = + useWriteContractsWithLogs({ + chain: basenameChain, + eventName: 'register_name', + }); - const { data, writeContractAsync, isPending, error } = useWriteContract(); - const { data: availableCapacities } = useCapabilities({ - account: address, - query: { enabled: isConnected && paymasterEnabled }, + // Transaction without paymaster + const { + initiateTransaction: initiateRegisterName, + transactionStatus: registerNameStatus, + transactionIsLoading: registerNameIsLoading, + transactionError: registerNameError, + } = useWriteContractWithReceipt({ + chain: basenameChain, + eventName: 'register_name', }); - const capabilities = useMemo(() => { - if (!isConnected || !chainId || !availableCapacities) { - return {}; - } - const chainCapabilities = availableCapacities[chainId]; - if (chainCapabilities.paymasterService?.supported) { - return { - paymasterService: { - // url: `${document.location.origin}/api/paymaster` - }, - }; - } - return {}; - }, [availableCapacities, chainId, isConnected]); - + // Params const normalizedName = normalizeEnsDomainName(name); - const { switchChainAsync } = useSwitchChain(); const isDiscounted = Boolean(discountKey && validationData); - const { logEventWithContext } = useAnalytics(); + // Callback const registerName = useCallback(async () => { if (!address) return; - if (chainId !== basenameChain.id) { - await switchChainAsync({ chainId: basenameChain.id }); - return; - } const addressData = encodeFunctionData({ abi: L2ResolverAbi, @@ -119,22 +98,17 @@ export function useRegisterNameCallback( reverseRecord, // Bool to decide whether to set this name as the "primary" name for the `owner`. }; - // Log attempt to register name - logEventWithContext('register_name_transaction_initiated', ActionType.click); - try { - if (!capabilities || Object.keys(capabilities).length === 0) { - await writeContractAsync({ + if (!paymasterServiceEnabled) { + await initiateRegisterName({ abi: REGISTER_CONTRACT_ABI, address: REGISTER_CONTRACT_ADDRESSES[basenameChain.id], - chainId: basenameChain.id, functionName: isDiscounted || IS_EARLY_ACCESS ? 'discountedRegister' : 'register', - // @ts-expect-error isDiscounted is sufficient guard for discountKey and validationData presence args: isDiscounted ? [registerRequest, discountKey, validationData] : [registerRequest], value, }); } else { - await writeContractsAsync({ + await initiateBatchCalls({ contracts: [ { abi: REGISTER_CONTRACT_ABI, @@ -143,46 +117,41 @@ export function useRegisterNameCallback( args: isDiscounted ? [registerRequest, discountKey, validationData] : [registerRequest], - // @ts-expect-error writeContractsAsync is incorrectly typed to not accept value value, }, ], - capabilities: capabilities, - chainId: basenameChain.id, + account: address, + chain: basenameChain, }); } } catch (e) { logError(e, 'Register name transaction canceled'); - logEventWithContext('register_name_transaction_canceled', ActionType.change); } }, [ address, - chainId, - basenameChain.id, + basenameChain, + discountKey, + initiateBatchCalls, + initiateRegisterName, + isDiscounted, + logError, name, normalizedName, - years, + paymasterServiceEnabled, reverseRecord, - logEventWithContext, - switchChainAsync, - capabilities, - writeContractAsync, - isDiscounted, - discountKey, validationData, value, - writeContractsAsync, - logError, + years, ]); return { callback: registerName, - data, - isPending: isPending ?? paymasterIsPending, - // @ts-expect-error error will be string renderable - error: error ?? paymasterError, + isPending: registerNameIsLoading || batchCallsIsLoading, + error: registerNameError ?? batchCallsError, reverseRecord, setReverseRecord, hasExistingBasename, + batchCallsStatus, + registerNameStatus, }; } diff --git a/apps/web/src/hooks/useWriteContractsWithLogs.ts b/apps/web/src/hooks/useWriteContractsWithLogs.ts index 4374e38410..5628e2ed60 100644 --- a/apps/web/src/hooks/useWriteContractsWithLogs.ts +++ b/apps/web/src/hooks/useWriteContractsWithLogs.ts @@ -55,7 +55,7 @@ export default function useWriteContractsWithLogs({ // Errors & Analytics const { logEventWithContext } = useAnalytics(); const { logError } = useErrors(); - const { atomicBatchEnabled } = useCapabilitiesSafe({ chain }); + const { atomicBatchEnabled } = useCapabilitiesSafe({ chainId: chain.id }); const { chain: connectedChain } = useAccount(); const [batchCallsStatus, setBatchCallsStatus] = useState(BatchCallsStatus.Idle); @@ -71,7 +71,7 @@ export default function useWriteContractsWithLogs({ } = useWriteContracts(); // Experimental: Track batch call status - const { data: sendCallsResult, isPending: sendCallsResultIsPending } = useCallsStatus({ + const { data: sendCallsResult, isFetching: sendCallsResultIsFetching } = useCallsStatus({ // @ts-expect-error: We can expect sendCallsId to be undefined since we're only enabling the query when defined id: sendCallsId, query: { @@ -179,7 +179,7 @@ export default function useWriteContractsWithLogs({ const batchCallsIsLoading = sendCallsIsPending || transactionReceiptIsFetching || - sendCallsResultIsPending || + sendCallsResultIsFetching || sendCallsResult?.status === 'PENDING'; const batchCallsIsSuccess = sendCallsIsSuccess && transactionReceiptIsSuccess; const batchCallsIsError = sendCallsIsError || transactionReceiptIsError; diff --git a/apps/web/src/utils/proofs/discount_code_storage.ts b/apps/web/src/utils/proofs/discount_code_storage.ts new file mode 100644 index 0000000000..806468c3dc --- /dev/null +++ b/apps/web/src/utils/proofs/discount_code_storage.ts @@ -0,0 +1,38 @@ +import { createKysely } from '@vercel/postgres-kysely'; + +type DiscountCodesTable = { + code: string; + expires_at: Date; + usage_limit: number; + usage_count: number; +}; + +type Database = { + 'public.basenames_discount_codes': DiscountCodesTable; +}; + +export enum DiscountCodeTableNamespace { + DiscountCodes = 'basenames_discount_codes', +} +const publicTableName = 'public.basenames_discount_codes'; + +export async function getDiscountCode(code: string) { + let query = createKysely().selectFrom(publicTableName).where('code', 'ilike', code); + return query.selectAll().limit(1).execute(); +} + +export async function incrementDiscountCodeUsage(code: string) { + const db = createKysely(); + const tableName = publicTableName; + + // Perform the update and return the updated row in a single query + const result = await db + .updateTable(tableName) + .set((eb) => ({ + usage_count: eb('usage_count', '+', 1), + })) + .where('code', 'ilike', code) + .executeTakeFirst(); + + return result; +} diff --git a/apps/web/src/utils/proofs/sybil_resistance.ts b/apps/web/src/utils/proofs/sybil_resistance.ts index 301e7fd583..af96f2b430 100644 --- a/apps/web/src/utils/proofs/sybil_resistance.ts +++ b/apps/web/src/utils/proofs/sybil_resistance.ts @@ -5,6 +5,7 @@ import RegistrarControllerABI from 'apps/web/src/abis/RegistrarControllerABI'; import { USERNAME_CB1_DISCOUNT_VALIDATORS, USERNAME_CB_DISCOUNT_VALIDATORS, + USERNAME_DISCOUNT_CODE_VALIDATORS, USERNAME_EA_DISCOUNT_VALIDATORS, } from 'apps/web/src/addresses/usernames'; import { getLinkedAddresses } from 'apps/web/src/cdp/api'; @@ -53,6 +54,9 @@ const discountTypes: DiscountTypesByChainId = { [DiscountType.EARLY_ACCESS]: { discountValidatorAddress: USERNAME_EA_DISCOUNT_VALIDATORS[base.id], }, + [DiscountType.DISCOUNT_CODE]: { + discountValidatorAddress: USERNAME_DISCOUNT_CODE_VALIDATORS[base.id], + }, }, [baseSepolia.id]: { [DiscountType.CB]: { @@ -66,6 +70,9 @@ const discountTypes: DiscountTypesByChainId = { [DiscountType.EARLY_ACCESS]: { discountValidatorAddress: USERNAME_EA_DISCOUNT_VALIDATORS[baseSepolia.id], }, + [DiscountType.DISCOUNT_CODE]: { + discountValidatorAddress: USERNAME_DISCOUNT_CODE_VALIDATORS[baseSepolia.id], + }, }, }; @@ -83,6 +90,22 @@ export async function hasRegisteredWithDiscount( }); } +async function getMessageSignature(message: `0x${string}`) { + // hash the message + const msgHash = keccak256(message); + + // sign the hashed message + const { r, s, v } = await sign({ + hash: msgHash, + privateKey: `0x${trustedSignerPKey}`, + }); + + // combine r, s, and v into a single signature + const signature = `${r.slice(2)}${s.slice(2)}${(v as bigint).toString(16)}`; + + return signature; +} + async function signMessageWithTrustedSigner( claimerAddress: Address, targetAddress: Address, @@ -97,17 +120,7 @@ async function signMessageWithTrustedSigner( ['0x1900', targetAddress, trustedSignerAddress, claimerAddress, BigInt(expiry)], ); - // hash the message - const msgHash = keccak256(message); - - // sign the hashed message - const { r, s, v } = await sign({ - hash: msgHash, - privateKey: `0x${trustedSignerPKey}`, - }); - - // combine r, s, and v into a single signature - const signature = `${r.slice(2)}${s.slice(2)}${(v as bigint).toString(16)}`; + const signature = await getMessageSignature(message); // return the encoded signed message return encodeAbiParameters(parseAbiParameters('address, uint64, bytes'), [ @@ -117,6 +130,31 @@ async function signMessageWithTrustedSigner( ]); } +export async function signDiscountMessageWithTrustedSigner( + claimerAddress: Address, + couponCodeUuid: Address, + targetAddress: Address, + expiry: number, +) { + if (!trustedSignerAddress || !isAddress(trustedSignerAddress)) { + throw new Error('Must provide a valid trustedSignerAddress'); + } + + const message = encodePacked( + ['bytes2', 'address', 'address', 'address', 'bytes32', 'uint64'], + ['0x1900', targetAddress, trustedSignerAddress, claimerAddress, couponCodeUuid, BigInt(expiry)], + ); + + const signature = await getMessageSignature(message); + + // return the encoded signed message + return encodeAbiParameters(parseAbiParameters('uint64, bytes32, bytes'), [ + BigInt(expiry), + couponCodeUuid, + `0x${signature}`, + ]); +} + export async function sybilResistantUsernameSigning( address: `0x${string}`, discountType: DiscountType, diff --git a/apps/web/src/utils/proofs/types.ts b/apps/web/src/utils/proofs/types.ts index 8fdc957dd8..9395a5246f 100644 --- a/apps/web/src/utils/proofs/types.ts +++ b/apps/web/src/utils/proofs/types.ts @@ -21,6 +21,7 @@ export enum DiscountType { CB = 'CB', CB1 = 'CB1', CB_ID = 'CB_ID', + DISCOUNT_CODE = 'DISCOUNT_CODE', } export type DiscountValue = { diff --git a/apps/web/src/utils/usernames.ts b/apps/web/src/utils/usernames.ts index d15562e491..417a3f26f5 100644 --- a/apps/web/src/utils/usernames.ts +++ b/apps/web/src/utils/usernames.ts @@ -391,6 +391,7 @@ export enum Discount { SUMMER_PASS_LVL_3 = 'SUMMER_PASS_LVL_3', BNS_NAME = 'BNS_NAME', BASE_DOT_ETH_NFT = 'BASE_DOT_ETH_NFT', + DISCOUNT_CODE = 'DISCOUNT_CODE', } export function isValidDiscount(key: string): key is keyof typeof Discount {