From 079a4dc7c59b63e1ace06a32b1fb0d1958a34451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Fri, 11 Oct 2024 14:27:26 -0400 Subject: [PATCH 01/17] discount code temp --- .../pages/api/proofs/discountCode/index.ts | 46 +++++++++++++ .../hooks/useAggregatedDiscountValidators.ts | 3 + apps/web/src/hooks/useAttestations.ts | 67 +++++++++++++++++++ apps/web/src/utils/proofs/proofs_storage.ts | 12 +++- 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 apps/web/pages/api/proofs/discountCode/index.ts 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..42378d57ae --- /dev/null +++ b/apps/web/pages/api/proofs/discountCode/index.ts @@ -0,0 +1,46 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getDiscountCode, ProofsException, proofValidation } from 'apps/web/src/utils/proofs'; +import { logger } from 'apps/web/src/utils/logger'; +import { withTimeout } from 'apps/web/pages/api/decorators'; + +/* +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, discountCode } = req.query; + const validationErr = proofValidation(address, chain); + if (validationErr) { + return res.status(validationErr.status).json({ error: validationErr.error }); + } + + try { + console.log({ address, chain, discountCode }); + const test = await getDiscountCode('LA_DINNER_TEST'); + + // const responseData = await getWalletProofs( + // // to lower case to be able to use index on huge dataset + // (address as string).toLowerCase() as `0x${string}`, + // parseInt(chain as string), + // ProofTableNamespace.DiscountCodes, + // false, + // ); + + console.log({ test }); + + return res.status(200).json(test); + } catch (error: unknown) { + if (error instanceof ProofsException) { + return res.status(error.statusCode).json({ error: error.message }); + } + console.log({ error }); + 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/src/hooks/useAggregatedDiscountValidators.ts b/apps/web/src/hooks/useAggregatedDiscountValidators.ts index 382d1624f8..6660eb3aa1 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'; @@ -50,6 +51,8 @@ export function useAggregatedDiscountValidators() { const { data: BaseDotEthData, loading: loadingBaseDotEth } = useBaseDotEthAttestations(); const { data: BNSData, loading: loadingBNS } = useBNSAttestations(); + const { data: DiscountCodeData, loading: loadingDiscountCode } = useDiscountCodeAttestations(); + console.log({ DiscountCodeData, loadingDiscountCode }); const loadingDiscounts = loadingCoinbaseAttestations || loadingCBIDAttestations || diff --git a/apps/web/src/hooks/useAttestations.ts b/apps/web/src/hooks/useAttestations.ts index c92b4ff7bb..2a90641978 100644 --- a/apps/web/src/hooks/useAttestations.ts +++ b/apps/web/src/hooks/useAttestations.ts @@ -487,3 +487,70 @@ export function useBNSAttestations() { } return { data: null, loading: isLoading, error }; } + +// returns info about Coinbase verified account attestations +export function useDiscountCodeAttestations() { + const { logError } = useErrors(); + const { address } = useAccount(); + const [loading, setLoading] = useState(false); + const [coinbaseProofResponse, setCoinbaseProofResponse] = useState( + null, + ); + const { basenameChain } = useBasenameChain(); + + useEffect(() => { + async function checkCoinbaseAttestations(a: string) { + try { + setLoading(true); + const params = new URLSearchParams(); + params.append('address', a); + params.append('chain', basenameChain.id.toString()); + params.append('discountCode', 'LA_DINNER'.toString()); + const response = await fetch(`/api/proofs/discountCode?${params}`); + const result = (await response.json()) as CoinbaseProofResponse; + if (response.ok) { + setCoinbaseProofResponse(result); + } + } catch (error) { + logError(error, 'Error checking Discount code'); + } finally { + setLoading(false); + } + } + + if (address && !IS_EARLY_ACCESS) { + checkCoinbaseAttestations(address).catch((error) => { + logError(error, 'Error checking Discount code'); + }); + } + }, [address, basenameChain.id, logError]); + + const signature = coinbaseProofResponse?.signedMessage as undefined | `0x${string}`; + + const readContractArgs = useMemo(() => { + if (!address || !signature) { + return {}; + } + return { + address: coinbaseProofResponse?.discountValidatorAddress, + abi: AttestationValidatorABI, + functionName: 'isValidDiscountRegistration', + args: [address, signature], + }; + }, [address, coinbaseProofResponse?.discountValidatorAddress, signature]); + + const { data: isValid, isLoading, error } = useReadContract(readContractArgs); + + if (isValid && coinbaseProofResponse && address && signature) { + return { + data: { + discountValidatorAddress: coinbaseProofResponse.discountValidatorAddress, + discount: Discount.COINBASE_VERIFIED_ACCOUNT, + validationData: signature, + }, + loading: false, + error: null, + }; + } + return { data: null, loading: loading || isLoading, error }; +} diff --git a/apps/web/src/utils/proofs/proofs_storage.ts b/apps/web/src/utils/proofs/proofs_storage.ts index efe6ba68ba..8ca10ee16d 100644 --- a/apps/web/src/utils/proofs/proofs_storage.ts +++ b/apps/web/src/utils/proofs/proofs_storage.ts @@ -10,6 +10,7 @@ export enum ProofTableNamespace { BNSDiscount = 'basenames_bns_discount', BaseEthHolders = 'basenames_base_eth_holders_discount', CBIDDiscount = 'basenames_cbid_discount', + DiscountCodes = 'basenames_discount_codes', } type ProofsTable = { @@ -18,7 +19,7 @@ type ProofsTable = { proofs: string; }; -//username_proofs +// username_proofs const proofTableName = 'proofs'; @@ -41,3 +42,12 @@ export async function getProofsByNamespaceAndAddress( } return query.selectAll().limit(1).execute(); } + +export async function getDiscountCode(code: string) { + const codeBuffer = Buffer.from(code); + const query = createKysely() + .selectFrom('public.basenames_discount_codes') + .where('code', '=', codeBuffer); + + return query.selectAll().limit(1).execute(); +} From cf03d2b1ce390b8a224370feb60b817407774633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Fri, 11 Oct 2024 15:49:26 -0400 Subject: [PATCH 02/17] comments --- .../pages/api/proofs/discountCode/index.ts | 19 +++++- apps/web/src/utils/proofs/proofs_storage.ts | 1 + apps/web/src/utils/proofs/sybil_resistance.ts | 61 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/web/pages/api/proofs/discountCode/index.ts b/apps/web/pages/api/proofs/discountCode/index.ts index 42378d57ae..faacd8b6bc 100644 --- a/apps/web/pages/api/proofs/discountCode/index.ts +++ b/apps/web/pages/api/proofs/discountCode/index.ts @@ -1,5 +1,10 @@ import type { NextApiRequest, NextApiResponse } from 'next'; -import { getDiscountCode, ProofsException, proofValidation } from 'apps/web/src/utils/proofs'; +import { + getDiscountCode, + ProofsException, + proofValidation, + signDiscountMessageWithTrustedSigner, +} from 'apps/web/src/utils/proofs'; import { logger } from 'apps/web/src/utils/logger'; import { withTimeout } from 'apps/web/pages/api/decorators'; @@ -19,7 +24,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { try { console.log({ address, chain, discountCode }); - const test = await getDiscountCode('LA_DINNER_TEST'); + + // 1. get the database model + const discountCode = await getDiscountCode('LA_DINNER_TEST'); // const responseData = await getWalletProofs( // // to lower case to be able to use index on huge dataset @@ -29,6 +36,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { // false, // ); + // 2. format the payload {} + // 3. sign (reference: signMessageWithTrustedSigner (will match validator expected paylaod)) + // 4. should return something similar to CoinbaseProofResponse + signDiscountMessageWithTrustedSigner( + '0xvalidatoraddress', + discountCode.expires_at, + discountCode.salt, + ); console.log({ test }); return res.status(200).json(test); diff --git a/apps/web/src/utils/proofs/proofs_storage.ts b/apps/web/src/utils/proofs/proofs_storage.ts index 8ca10ee16d..fbdc97914d 100644 --- a/apps/web/src/utils/proofs/proofs_storage.ts +++ b/apps/web/src/utils/proofs/proofs_storage.ts @@ -43,6 +43,7 @@ export async function getProofsByNamespaceAndAddress( return query.selectAll().limit(1).execute(); } +// Not a proof: should be somewhere else? export async function getDiscountCode(code: string) { const codeBuffer = Buffer.from(code); const query = createKysely() diff --git a/apps/web/src/utils/proofs/sybil_resistance.ts b/apps/web/src/utils/proofs/sybil_resistance.ts index 301e7fd583..16fdb4f4c2 100644 --- a/apps/web/src/utils/proofs/sybil_resistance.ts +++ b/apps/web/src/utils/proofs/sybil_resistance.ts @@ -83,6 +83,7 @@ export async function hasRegisteredWithDiscount( }); } +// message: `0x${string}`? async function signMessageWithTrustedSigner( claimerAddress: Address, targetAddress: Address, @@ -117,6 +118,66 @@ async function signMessageWithTrustedSigner( ]); } +export async function signDiscountMessageWithTrustedSigner( + targetAddress: Address, + expiry: Date, + salt: string, +) { + if (!trustedSignerAddress || !isAddress(trustedSignerAddress)) { + throw new Error('Must provide a valid trustedSignerAddress'); + } + // encode the message + const expiryTimestamp = BigInt(Math.floor(expiry.getTime() / 1000)); + + // uuid: string => bytes32 + + // validatoraddres + // signer (trust) + // claimer + // couppoun => (bytes32) + // expires => (bytes32) + + const message = encodePacked( + ['bytes2', 'address', 'address', 'address', 'uint64', 'string'], + ['0x1900', targetAddress, trustedSignerAddress, '0x2', expiryTimestamp, salt], + ); + + // hex"1900", + // target, + // signer, + // uuid, + // expiry, + // salt + // where `target` is the address of the deployed discount validator,`signer` is the expected signer, `nonce` is the `uuid` of the coupon and `expiry` is the uint64 expiry timestamp. + + // 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)}`; + + // + // abi.encode( + // uint64 expiry, + // bytes32 uuid, + // uint256 salt, + // bytes sig + // ) + + // return the encoded signed message + return encodeAbiParameters(parseAbiParameters('address, uint64, bytes'), [ + claimerAddress, + BigInt(expiry), + `0x${signature}`, + ]); +} + export async function sybilResistantUsernameSigning( address: `0x${string}`, discountType: DiscountType, From 364a386864495705701ab07c714fed676bf85479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Mon, 14 Oct 2024 14:29:57 -0400 Subject: [PATCH 03/17] BE: Proof of concept --- .../api/proofs/discountCode/consume/index.ts | 27 ++++ .../pages/api/proofs/discountCode/index.ts | 62 ++++----- apps/web/src/addresses/usernames.ts | 5 + .../hooks/useAggregatedDiscountValidators.ts | 16 ++- apps/web/src/hooks/useAttestations.ts | 33 ++--- .../src/utils/proofs/discount_code_storage.ts | 38 ++++++ apps/web/src/utils/proofs/proofs_storage.ts | 11 -- apps/web/src/utils/proofs/sybil_resistance.ts | 126 ++++++++++++++---- apps/web/src/utils/proofs/types.ts | 1 + apps/web/src/utils/usernames.ts | 1 + 10 files changed, 235 insertions(+), 85 deletions(-) create mode 100644 apps/web/pages/api/proofs/discountCode/consume/index.ts create mode 100644 apps/web/src/utils/proofs/discount_code_storage.ts 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..69220bc071 --- /dev/null +++ b/apps/web/pages/api/proofs/discountCode/consume/index.ts @@ -0,0 +1,27 @@ +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 +*/ +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'method not allowed' }); + } + + try { + // 1. get the database model + await incrementDiscountCodeUsage('LA_DINNER_TEST'); + + return res.status(200).json({ success: true }); + } catch (error: unknown) { + console.log({ error }); + 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 index faacd8b6bc..93d7981c39 100644 --- a/apps/web/pages/api/proofs/discountCode/index.ts +++ b/apps/web/pages/api/proofs/discountCode/index.ts @@ -1,12 +1,17 @@ import type { NextApiRequest, NextApiResponse } from 'next'; -import { - getDiscountCode, - ProofsException, - proofValidation, - signDiscountMessageWithTrustedSigner, -} from 'apps/web/src/utils/proofs'; +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 { baseSepolia } from 'viem/chains'; +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 @@ -16,41 +21,36 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'GET') { return res.status(405).json({ error: 'method not allowed' }); } - const { address, chain, discountCode } = req.query; + const { address, chain } = req.query; const validationErr = proofValidation(address, chain); if (validationErr) { return res.status(validationErr.status).json({ error: validationErr.error }); } try { - console.log({ address, chain, discountCode }); - // 1. get the database model - const discountCode = await getDiscountCode('LA_DINNER_TEST'); - - // const responseData = await getWalletProofs( - // // to lower case to be able to use index on huge dataset - // (address as string).toLowerCase() as `0x${string}`, - // parseInt(chain as string), - // ProofTableNamespace.DiscountCodes, - // false, - // ); - - // 2. format the payload {} - // 3. sign (reference: signMessageWithTrustedSigner (will match validator expected paylaod)) - // 4. should return something similar to CoinbaseProofResponse - signDiscountMessageWithTrustedSigner( - '0xvalidatoraddress', - discountCode.expires_at, - discountCode.salt, + const discountCodes = await getDiscountCode('LA_DINNER_TEST'); + const discountCode = discountCodes[0]; + + // 2. Sign the payload + 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[baseSepolia.id], + expirationTimeUnix, ); - console.log({ test }); - return res.status(200).json(test); + const result: DiscountCodeResponse = { + discountValidatorAddress: USERNAME_DISCOUNT_CODE_VALIDATORS[baseSepolia.id], + address: address as Address, + signedMessage: signature, + }; + + return res.status(200).json(result); } catch (error: unknown) { - if (error instanceof ProofsException) { - return res.status(error.statusCode).json({ error: error.message }); - } console.log({ error }); logger.error('error getting proofs for discount code', error); } diff --git a/apps/web/src/addresses/usernames.ts b/apps/web/src/addresses/usernames.ts index 6755eb0ded..caf41c66a1 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]: '0x6E89d99643DB1223697C77A9F8B2Cb07E898e743', +}; diff --git a/apps/web/src/hooks/useAggregatedDiscountValidators.ts b/apps/web/src/hooks/useAggregatedDiscountValidators.ts index 6660eb3aa1..bfde98d7dc 100644 --- a/apps/web/src/hooks/useAggregatedDiscountValidators.ts +++ b/apps/web/src/hooks/useAggregatedDiscountValidators.ts @@ -52,7 +52,7 @@ export function useAggregatedDiscountValidators() { const { data: BNSData, loading: loadingBNS } = useBNSAttestations(); const { data: DiscountCodeData, loading: loadingDiscountCode } = useDiscountCodeAttestations(); - console.log({ DiscountCodeData, loadingDiscountCode }); + const loadingDiscounts = loadingCoinbaseAttestations || loadingCBIDAttestations || @@ -62,7 +62,8 @@ export function useAggregatedDiscountValidators() { loadingBuildathon || loadingSummerPass || loadingBaseDotEth || - loadingBNS; + loadingBNS || + loadingDiscountCode; const discountsToAttestationData = useMemo(() => { const discountMapping: MappedDiscountData = {}; @@ -117,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; @@ -130,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 2a90641978..0662b737c5 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'; @@ -488,18 +489,18 @@ export function useBNSAttestations() { return { data: null, loading: isLoading, error }; } -// returns info about Coinbase verified account attestations +// returns info about Discount Codes attestations export function useDiscountCodeAttestations() { const { logError } = useErrors(); const { address } = useAccount(); const [loading, setLoading] = useState(false); - const [coinbaseProofResponse, setCoinbaseProofResponse] = useState( + const [discountCodeResponse, setDiscountCodeResponse] = useState( null, ); const { basenameChain } = useBasenameChain(); useEffect(() => { - async function checkCoinbaseAttestations(a: string) { + async function checkDiscountCode(a: string) { try { setLoading(true); const params = new URLSearchParams(); @@ -507,9 +508,9 @@ export function useDiscountCodeAttestations() { params.append('chain', basenameChain.id.toString()); params.append('discountCode', 'LA_DINNER'.toString()); const response = await fetch(`/api/proofs/discountCode?${params}`); - const result = (await response.json()) as CoinbaseProofResponse; + const result = (await response.json()) as DiscountCodeResponse; if (response.ok) { - setCoinbaseProofResponse(result); + setDiscountCodeResponse(result); } } catch (error) { logError(error, 'Error checking Discount code'); @@ -519,33 +520,35 @@ export function useDiscountCodeAttestations() { } if (address && !IS_EARLY_ACCESS) { - checkCoinbaseAttestations(address).catch((error) => { + checkDiscountCode(address).catch((error) => { logError(error, 'Error checking Discount code'); }); } }, [address, basenameChain.id, logError]); - const signature = coinbaseProofResponse?.signedMessage as undefined | `0x${string}`; - + const signature = discountCodeResponse?.signedMessage; + console.log({ signature, address }); const readContractArgs = useMemo(() => { if (!address || !signature) { return {}; } + + console.log('READ'); return { - address: coinbaseProofResponse?.discountValidatorAddress, + address: discountCodeResponse?.discountValidatorAddress, abi: AttestationValidatorABI, functionName: 'isValidDiscountRegistration', args: [address, signature], }; - }, [address, coinbaseProofResponse?.discountValidatorAddress, signature]); - - const { data: isValid, isLoading, error } = useReadContract(readContractArgs); + }, [address, discountCodeResponse?.discountValidatorAddress, signature]); - if (isValid && coinbaseProofResponse && address && signature) { + const { data: isValid, isLoading, error, isError } = useReadContract(readContractArgs); + console.log({ isValid, isLoading, error, isError }); + if (isValid && discountCodeResponse && address && signature) { return { data: { - discountValidatorAddress: coinbaseProofResponse.discountValidatorAddress, - discount: Discount.COINBASE_VERIFIED_ACCOUNT, + discountValidatorAddress: discountCodeResponse.discountValidatorAddress, + discount: Discount.DISCOUNT_CODE, validationData: signature, }, loading: false, 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/proofs_storage.ts b/apps/web/src/utils/proofs/proofs_storage.ts index fbdc97914d..dd37f29f5b 100644 --- a/apps/web/src/utils/proofs/proofs_storage.ts +++ b/apps/web/src/utils/proofs/proofs_storage.ts @@ -10,7 +10,6 @@ export enum ProofTableNamespace { BNSDiscount = 'basenames_bns_discount', BaseEthHolders = 'basenames_base_eth_holders_discount', CBIDDiscount = 'basenames_cbid_discount', - DiscountCodes = 'basenames_discount_codes', } type ProofsTable = { @@ -42,13 +41,3 @@ export async function getProofsByNamespaceAndAddress( } return query.selectAll().limit(1).execute(); } - -// Not a proof: should be somewhere else? -export async function getDiscountCode(code: string) { - const codeBuffer = Buffer.from(code); - const query = createKysely() - .selectFrom('public.basenames_discount_codes') - .where('code', '=', codeBuffer); - - return query.selectAll().limit(1).execute(); -} diff --git a/apps/web/src/utils/proofs/sybil_resistance.ts b/apps/web/src/utils/proofs/sybil_resistance.ts index 16fdb4f4c2..22043d1ef2 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'; @@ -66,6 +67,9 @@ const discountTypes: DiscountTypesByChainId = { [DiscountType.EARLY_ACCESS]: { discountValidatorAddress: USERNAME_EA_DISCOUNT_VALIDATORS[baseSepolia.id], }, + [DiscountType.DISCOUNT_CODE]: { + discountValidatorAddress: USERNAME_DISCOUNT_CODE_VALIDATORS[baseSepolia.id], + }, }, }; @@ -119,37 +123,27 @@ async function signMessageWithTrustedSigner( } export async function signDiscountMessageWithTrustedSigner( + claimerAddress: Address, + couponCodeUuid: Address, targetAddress: Address, - expiry: Date, - salt: string, + expiry: number, ) { if (!trustedSignerAddress || !isAddress(trustedSignerAddress)) { throw new Error('Must provide a valid trustedSignerAddress'); } - // encode the message - const expiryTimestamp = BigInt(Math.floor(expiry.getTime() / 1000)); // uuid: string => bytes32 - - // validatoraddres + // targetAddress validatoraddres // signer (trust) // claimer // couppoun => (bytes32) // expires => (bytes32) const message = encodePacked( - ['bytes2', 'address', 'address', 'address', 'uint64', 'string'], - ['0x1900', targetAddress, trustedSignerAddress, '0x2', expiryTimestamp, salt], + ['bytes2', 'address', 'address', 'address', 'bytes32', 'uint64'], + ['0x1900', targetAddress, trustedSignerAddress, claimerAddress, couponCodeUuid, BigInt(expiry)], ); - // hex"1900", - // target, - // signer, - // uuid, - // expiry, - // salt - // where `target` is the address of the deployed discount validator,`signer` is the expected signer, `nonce` is the `uuid` of the coupon and `expiry` is the uint64 expiry timestamp. - // hash the message const msgHash = keccak256(message); @@ -162,18 +156,10 @@ export async function signDiscountMessageWithTrustedSigner( // combine r, s, and v into a single signature const signature = `${r.slice(2)}${s.slice(2)}${(v as bigint).toString(16)}`; - // - // abi.encode( - // uint64 expiry, - // bytes32 uuid, - // uint256 salt, - // bytes sig - // ) - // return the encoded signed message - return encodeAbiParameters(parseAbiParameters('address, uint64, bytes'), [ - claimerAddress, + return encodeAbiParameters(parseAbiParameters('uint64, bytes32, bytes'), [ BigInt(expiry), + couponCodeUuid, `0x${signature}`, ]); } @@ -262,3 +248,91 @@ export async function sybilResistantUsernameSigning( throw error; } } + +export async function sybilResistantUsernameSigningBadDuplicate( + address: `0x${string}`, + discountType: DiscountType, + chainId: number, + couponCodeUuid: Address, + expirationTimeUnix: number, +): Promise { + const schema = discountTypes[chainId][discountType]?.schemaId; + + const discountValidatorAddress = discountTypes[chainId][discountType]?.discountValidatorAddress; + + if (!discountValidatorAddress || !isAddress(discountValidatorAddress)) { + throw new ProofsException('Must provide a valid discountValidatorAddress', 500); + } + + const attestations = await getAttestations( + address, + // @ts-expect-error onchainkit expects a different type for Chain (??) + { id: chainId }, + { schemas: [schema] }, + ); + + if (!attestations?.length) { + return { attestations: [], discountValidatorAddress }; + } + const attestationsRes = attestations.map( + (attestation) => JSON.parse(attestation.decodedDataJson)[0] as VerifiedAccount, + ); + + let { linkedAddresses, idemKey } = await getLinkedAddresses(address as string); + + const hasPreviouslyRegistered = await hasRegisteredWithDiscount(linkedAddresses, chainId); + + // if any linked address registered previously return an error + if (hasPreviouslyRegistered) { + throw new ProofsException('You have already claimed a discounted basename (onchain).', 409); + } + + const kvKey = `${previousClaimsKVPrefix}${idemKey}`; + //check kv for previous claim entries + let previousClaims = (await kv.get(kvKey)) ?? {}; + const previousClaim = previousClaims[discountType]; + if (previousClaim) { + if (previousClaim.address != address) { + throw new ProofsException( + 'You tried claiming this with a different address, wait a couple minutes to try again.', + 400, + ); + } + // return previously signed message + return { + signedMessage: previousClaim.signedMessage, + attestations: attestationsRes, + discountValidatorAddress, + expires: expirationTimeUnix.toString(), + }; + } + + try { + // generate and sign the message + + const signedMessage = await signDiscountMessageWithTrustedSigner( + address, + couponCodeUuid, + '0x52acEeB464F600437a3681bEC087fb53F3f75638', + expirationTimeUnix, + ); + + const claim: PreviousClaim = { address, signedMessage }; + previousClaims[discountType] = claim; + + await kv.set(kvKey, previousClaims, { nx: true, ex: parseInt(EXPIRY) }); + + return { + signedMessage: claim.signedMessage, + attestations: attestationsRes, + discountValidatorAddress, + expires: EXPIRY, + }; + } catch (error) { + logger.error('error while getting sybilResistant basename signature', error); + if (error instanceof Error) { + throw new ProofsException(error.message, 500); + } + throw error; + } +} 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 329dbc9b07..701b8f3038 100644 --- a/apps/web/src/utils/usernames.ts +++ b/apps/web/src/utils/usernames.ts @@ -371,6 +371,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 { From 7ac0cde2bfadb3f7cb0fcafe4838f58c7bf0eb66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Tue, 15 Oct 2024 15:33:00 -0400 Subject: [PATCH 04/17] mark/increment code as consumed --- .../api/proofs/discountCode/consume/index.ts | 16 +++++-- .../pages/api/proofs/discountCode/index.ts | 21 +++++++-- .../Basenames/RegistrationContext.tsx | 43 ++++++++++++++++++- .../hooks/useAggregatedDiscountValidators.ts | 6 +-- apps/web/src/hooks/useAttestations.ts | 23 +++++----- 5 files changed, 86 insertions(+), 23 deletions(-) diff --git a/apps/web/pages/api/proofs/discountCode/consume/index.ts b/apps/web/pages/api/proofs/discountCode/consume/index.ts index 69220bc071..b70d9d0c0d 100644 --- a/apps/web/pages/api/proofs/discountCode/consume/index.ts +++ b/apps/web/pages/api/proofs/discountCode/consume/index.ts @@ -6,18 +6,28 @@ import { incrementDiscountCodeUsage } from 'apps/web/src/utils/proofs/discount_c /* 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' }); + 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' }); + } + // 1. get the database model - await incrementDiscountCodeUsage('LA_DINNER_TEST'); + await incrementDiscountCodeUsage(code); return res.status(200).json({ success: true }); } catch (error: unknown) { - console.log({ error }); logger.error('error incrementing the discount code', error); } // If error is not an instance of Error, return a generic error message diff --git a/apps/web/pages/api/proofs/discountCode/index.ts b/apps/web/pages/api/proofs/discountCode/index.ts index 93d7981c39..6060085097 100644 --- a/apps/web/pages/api/proofs/discountCode/index.ts +++ b/apps/web/pages/api/proofs/discountCode/index.ts @@ -15,7 +15,6 @@ export type DiscountCodeResponse = { /* this endpoint returns whether or a discount code is valid - */ async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'GET') { @@ -30,9 +29,25 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { try { // 1. get the database model const discountCodes = await getDiscountCode('LA_DINNER_TEST'); + + // 2. Validation: Coupon exists + if (!discountCodes || discountCodes.length === 0) { + return res.status(500).json({ error: 'Discount code invalid' }); + } + const discountCode = discountCodes[0]; - // 2. Sign the payload + // 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); @@ -43,6 +58,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { expirationTimeUnix, ); + // 4. Return the discount data const result: DiscountCodeResponse = { discountValidatorAddress: USERNAME_DISCOUNT_CODE_VALIDATORS[baseSepolia.id], address: address as Address, @@ -51,7 +67,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return res.status(200).json(result); } catch (error: unknown) { - console.log({ error }); logger.error('error getting proofs for discount code', error); } // If error is not an instance of Error, return a generic error message diff --git a/apps/web/src/components/Basenames/RegistrationContext.tsx b/apps/web/src/components/Basenames/RegistrationContext.tsx index ff958c9deb..89d23b52e9 100644 --- a/apps/web/src/components/Basenames/RegistrationContext.tsx +++ b/apps/web/src/components/Basenames/RegistrationContext.tsx @@ -10,7 +10,7 @@ import useBaseEnsName from 'apps/web/src/hooks/useBaseEnsName'; import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; import { Discount, formatBaseEthDomain, isValidDiscount } from 'apps/web/src/utils/usernames'; import { ActionType } from 'libs/base-ui/utils/logEvent'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { Dispatch, ReactNode, @@ -20,6 +20,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; import { useInterval } from 'usehooks-ts'; @@ -119,8 +120,12 @@ export default function RegistrationProvider({ children }: RegistrationProviderP address, }); + // Discount code from URL + const searchParams = useSearchParams(); + const code = searchParams?.get('code'); + // Username discount states - const { data: discounts, loading: loadingDiscounts } = useAggregatedDiscountValidators(); + const { data: discounts, loading: loadingDiscounts } = useAggregatedDiscountValidators(code); const discount = findFirstValidDiscount(discounts); const allActiveDiscounts = useMemo( @@ -210,12 +215,46 @@ export default function RegistrationProvider({ children }: RegistrationProviderP transactionIsSuccess, ]); + // Move from search to claim useEffect(() => { if (selectedName.length) { setRegistrationStep(RegistrationSteps.Claim); } }, [selectedName.length]); + // 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(() => { logEventWithContext(`step_${registrationStep}`, ActionType.change); diff --git a/apps/web/src/hooks/useAggregatedDiscountValidators.ts b/apps/web/src/hooks/useAggregatedDiscountValidators.ts index bfde98d7dc..f46eb35cab 100644 --- a/apps/web/src/hooks/useAggregatedDiscountValidators.ts +++ b/apps/web/src/hooks/useAggregatedDiscountValidators.ts @@ -38,7 +38,7 @@ export function findFirstValidDiscount( return sortedDiscounts.find((data) => data?.discountKey) ?? undefined; } -export function useAggregatedDiscountValidators() { +export function useAggregatedDiscountValidators(code: string | null | undefined) { const { data: activeDiscountValidators, isLoading: loadingActiveDiscounts } = useActiveDiscountValidators(); const { data: CBIDData, loading: loadingCBIDAttestations } = useCheckCBIDAttestations(); @@ -50,8 +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(); + const { data: DiscountCodeData, loading: loadingDiscountCode } = + useDiscountCodeAttestations(code); const loadingDiscounts = loadingCoinbaseAttestations || diff --git a/apps/web/src/hooks/useAttestations.ts b/apps/web/src/hooks/useAttestations.ts index 0662b737c5..17b12d1484 100644 --- a/apps/web/src/hooks/useAttestations.ts +++ b/apps/web/src/hooks/useAttestations.ts @@ -490,23 +490,24 @@ export function useBNSAttestations() { } // returns info about Discount Codes attestations -export function useDiscountCodeAttestations() { +export function useDiscountCodeAttestations(code: string | null | undefined) { 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) { + 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('discountCode', 'LA_DINNER'.toString()); + params.append('discountCode', c.toString()); const response = await fetch(`/api/proofs/discountCode?${params}`); const result = (await response.json()) as DiscountCodeResponse; if (response.ok) { @@ -519,31 +520,29 @@ export function useDiscountCodeAttestations() { } } - if (address && !IS_EARLY_ACCESS) { - checkDiscountCode(address).catch((error) => { + if (address && !IS_EARLY_ACCESS && !!code) { + checkDiscountCode(address, code).catch((error) => { logError(error, 'Error checking Discount code'); }); } - }, [address, basenameChain.id, logError]); + }, [address, basenameChain.id, code, logError]); const signature = discountCodeResponse?.signedMessage; - console.log({ signature, address }); const readContractArgs = useMemo(() => { - if (!address || !signature) { + if (!address || !signature || !code) { return {}; } - console.log('READ'); return { address: discountCodeResponse?.discountValidatorAddress, abi: AttestationValidatorABI, functionName: 'isValidDiscountRegistration', args: [address, signature], }; - }, [address, discountCodeResponse?.discountValidatorAddress, signature]); + }, [address, code, discountCodeResponse?.discountValidatorAddress, signature]); + + const { data: isValid, isLoading, error } = useReadContract(readContractArgs); - const { data: isValid, isLoading, error, isError } = useReadContract(readContractArgs); - console.log({ isValid, isLoading, error, isError }); if (isValid && discountCodeResponse && address && signature) { return { data: { From c53ce6c6f4e42927d7faa15e701943d2caada562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Tue, 15 Oct 2024 15:40:34 -0400 Subject: [PATCH 05/17] bad code --- apps/web/src/utils/proofs/sybil_resistance.ts | 88 ------------------- 1 file changed, 88 deletions(-) diff --git a/apps/web/src/utils/proofs/sybil_resistance.ts b/apps/web/src/utils/proofs/sybil_resistance.ts index 22043d1ef2..147a492e76 100644 --- a/apps/web/src/utils/proofs/sybil_resistance.ts +++ b/apps/web/src/utils/proofs/sybil_resistance.ts @@ -248,91 +248,3 @@ export async function sybilResistantUsernameSigning( throw error; } } - -export async function sybilResistantUsernameSigningBadDuplicate( - address: `0x${string}`, - discountType: DiscountType, - chainId: number, - couponCodeUuid: Address, - expirationTimeUnix: number, -): Promise { - const schema = discountTypes[chainId][discountType]?.schemaId; - - const discountValidatorAddress = discountTypes[chainId][discountType]?.discountValidatorAddress; - - if (!discountValidatorAddress || !isAddress(discountValidatorAddress)) { - throw new ProofsException('Must provide a valid discountValidatorAddress', 500); - } - - const attestations = await getAttestations( - address, - // @ts-expect-error onchainkit expects a different type for Chain (??) - { id: chainId }, - { schemas: [schema] }, - ); - - if (!attestations?.length) { - return { attestations: [], discountValidatorAddress }; - } - const attestationsRes = attestations.map( - (attestation) => JSON.parse(attestation.decodedDataJson)[0] as VerifiedAccount, - ); - - let { linkedAddresses, idemKey } = await getLinkedAddresses(address as string); - - const hasPreviouslyRegistered = await hasRegisteredWithDiscount(linkedAddresses, chainId); - - // if any linked address registered previously return an error - if (hasPreviouslyRegistered) { - throw new ProofsException('You have already claimed a discounted basename (onchain).', 409); - } - - const kvKey = `${previousClaimsKVPrefix}${idemKey}`; - //check kv for previous claim entries - let previousClaims = (await kv.get(kvKey)) ?? {}; - const previousClaim = previousClaims[discountType]; - if (previousClaim) { - if (previousClaim.address != address) { - throw new ProofsException( - 'You tried claiming this with a different address, wait a couple minutes to try again.', - 400, - ); - } - // return previously signed message - return { - signedMessage: previousClaim.signedMessage, - attestations: attestationsRes, - discountValidatorAddress, - expires: expirationTimeUnix.toString(), - }; - } - - try { - // generate and sign the message - - const signedMessage = await signDiscountMessageWithTrustedSigner( - address, - couponCodeUuid, - '0x52acEeB464F600437a3681bEC087fb53F3f75638', - expirationTimeUnix, - ); - - const claim: PreviousClaim = { address, signedMessage }; - previousClaims[discountType] = claim; - - await kv.set(kvKey, previousClaims, { nx: true, ex: parseInt(EXPIRY) }); - - return { - signedMessage: claim.signedMessage, - attestations: attestationsRes, - discountValidatorAddress, - expires: EXPIRY, - }; - } catch (error) { - logger.error('error while getting sybilResistant basename signature', error); - if (error instanceof Error) { - throw new ProofsException(error.message, 500); - } - throw error; - } -} From 91b8dcdf68ace9119cab6713087eeacb6e5f9beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Tue, 15 Oct 2024 15:41:57 -0400 Subject: [PATCH 06/17] bad code --- apps/web/src/utils/proofs/sybil_resistance.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/web/src/utils/proofs/sybil_resistance.ts b/apps/web/src/utils/proofs/sybil_resistance.ts index 147a492e76..1fd4419adc 100644 --- a/apps/web/src/utils/proofs/sybil_resistance.ts +++ b/apps/web/src/utils/proofs/sybil_resistance.ts @@ -87,7 +87,6 @@ export async function hasRegisteredWithDiscount( }); } -// message: `0x${string}`? async function signMessageWithTrustedSigner( claimerAddress: Address, targetAddress: Address, @@ -132,13 +131,6 @@ export async function signDiscountMessageWithTrustedSigner( throw new Error('Must provide a valid trustedSignerAddress'); } - // uuid: string => bytes32 - // targetAddress validatoraddres - // signer (trust) - // claimer - // couppoun => (bytes32) - // expires => (bytes32) - const message = encodePacked( ['bytes2', 'address', 'address', 'address', 'bytes32', 'uint64'], ['0x1900', targetAddress, trustedSignerAddress, claimerAddress, couponCodeUuid, BigInt(expiry)], From 2dec1b7562fce9499a38abec0a09090fb8b91ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Tue, 15 Oct 2024 15:45:05 -0400 Subject: [PATCH 07/17] dry it up --- apps/web/src/utils/proofs/sybil_resistance.ts | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/apps/web/src/utils/proofs/sybil_resistance.ts b/apps/web/src/utils/proofs/sybil_resistance.ts index 1fd4419adc..dd7ccadbe9 100644 --- a/apps/web/src/utils/proofs/sybil_resistance.ts +++ b/apps/web/src/utils/proofs/sybil_resistance.ts @@ -87,6 +87,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, @@ -101,17 +117,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 = getMessageSignature(message); // return the encoded signed message return encodeAbiParameters(parseAbiParameters('address, uint64, bytes'), [ @@ -136,17 +142,7 @@ export async function signDiscountMessageWithTrustedSigner( ['0x1900', targetAddress, trustedSignerAddress, claimerAddress, couponCodeUuid, 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 = getMessageSignature(message); // return the encoded signed message return encodeAbiParameters(parseAbiParameters('uint64, bytes32, bytes'), [ From 797c5b600b8ec52222888c1a1f38071743ed13c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Tue, 15 Oct 2024 15:47:20 -0400 Subject: [PATCH 08/17] remove test code --- apps/web/pages/api/proofs/discountCode/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/pages/api/proofs/discountCode/index.ts b/apps/web/pages/api/proofs/discountCode/index.ts index 6060085097..9caac534cc 100644 --- a/apps/web/pages/api/proofs/discountCode/index.ts +++ b/apps/web/pages/api/proofs/discountCode/index.ts @@ -20,15 +20,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'GET') { return res.status(405).json({ error: 'method not allowed' }); } - const { address, chain } = req.query; + const { address, chain, code } = req.query; const validationErr = proofValidation(address, chain); if (validationErr) { return res.status(validationErr.status).json({ error: validationErr.error }); } + if (!code || typeof code !== 'string') { + return res.status(500).json({ error: 'Discount code invalid' }); + } + try { // 1. get the database model - const discountCodes = await getDiscountCode('LA_DINNER_TEST'); + const discountCodes = await getDiscountCode(code); // 2. Validation: Coupon exists if (!discountCodes || discountCodes.length === 0) { From e0157fee6094a99a96bb1c16d933d6e11022fac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Tue, 15 Oct 2024 16:09:47 -0400 Subject: [PATCH 09/17] cleanup and fix --- apps/web/src/hooks/useAttestations.ts | 2 +- apps/web/src/utils/proofs/sybil_resistance.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/useAttestations.ts b/apps/web/src/hooks/useAttestations.ts index 17b12d1484..d008a1de14 100644 --- a/apps/web/src/hooks/useAttestations.ts +++ b/apps/web/src/hooks/useAttestations.ts @@ -507,7 +507,7 @@ export function useDiscountCodeAttestations(code: string | null | undefined) { const params = new URLSearchParams(); params.append('address', a); params.append('chain', basenameChain.id.toString()); - params.append('discountCode', c.toString()); + params.append('code', c.toString()); const response = await fetch(`/api/proofs/discountCode?${params}`); const result = (await response.json()) as DiscountCodeResponse; if (response.ok) { diff --git a/apps/web/src/utils/proofs/sybil_resistance.ts b/apps/web/src/utils/proofs/sybil_resistance.ts index dd7ccadbe9..f4dea8d3fe 100644 --- a/apps/web/src/utils/proofs/sybil_resistance.ts +++ b/apps/web/src/utils/proofs/sybil_resistance.ts @@ -117,7 +117,7 @@ async function signMessageWithTrustedSigner( ['0x1900', targetAddress, trustedSignerAddress, claimerAddress, BigInt(expiry)], ); - const signature = getMessageSignature(message); + const signature = await getMessageSignature(message); // return the encoded signed message return encodeAbiParameters(parseAbiParameters('address, uint64, bytes'), [ @@ -142,7 +142,7 @@ export async function signDiscountMessageWithTrustedSigner( ['0x1900', targetAddress, trustedSignerAddress, claimerAddress, couponCodeUuid, BigInt(expiry)], ); - const signature = getMessageSignature(message); + const signature = await getMessageSignature(message); // return the encoded signed message return encodeAbiParameters(parseAbiParameters('uint64, bytes32, bytes'), [ From ef27ac7550242420822687820c743eddad04f1dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Tue, 15 Oct 2024 16:13:32 -0400 Subject: [PATCH 10/17] suspense --- apps/web/app/(basenames)/names/page.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(basenames)/names/page.tsx b/apps/web/app/(basenames)/names/page.tsx index 738fdd54c4..4caa237719 100644 --- a/apps/web/app/(basenames)/names/page.tsx +++ b/apps/web/app/(basenames)/names/page.tsx @@ -31,16 +31,16 @@ export const metadata: Metadata = { export default async function Page() { return ( - - + +
-
-
+ +
); } From 48fd67276002277e3f43f235520f48c78107a7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Wed, 16 Oct 2024 09:51:31 -0400 Subject: [PATCH 11/17] undo change --- apps/web/src/utils/proofs/proofs_storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/utils/proofs/proofs_storage.ts b/apps/web/src/utils/proofs/proofs_storage.ts index dd37f29f5b..efe6ba68ba 100644 --- a/apps/web/src/utils/proofs/proofs_storage.ts +++ b/apps/web/src/utils/proofs/proofs_storage.ts @@ -18,7 +18,7 @@ type ProofsTable = { proofs: string; }; -// username_proofs +//username_proofs const proofTableName = 'proofs'; From 44dcce1b0394c67b14ba90fe15c418e9bb6ebc59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Wed, 16 Oct 2024 13:07:29 -0400 Subject: [PATCH 12/17] refactor: use internal transaction/capacities hooks for registration flow --- .../Basenames/RegistrationContext.tsx | 209 +++++++++--------- .../Basenames/RegistrationForm/index.tsx | 59 ++--- apps/web/src/hooks/useCapabilitiesSafe.ts | 13 +- apps/web/src/hooks/useRegisterNameCallback.ts | 121 ++++------ .../src/hooks/useWriteContractsWithLogs.ts | 6 +- 5 files changed, 176 insertions(+), 232 deletions(-) diff --git a/apps/web/src/components/Basenames/RegistrationContext.tsx b/apps/web/src/components/Basenames/RegistrationContext.tsx index 89d23b52e9..3878b9ca3d 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, useSearchParams } from 'next/navigation'; @@ -23,10 +25,15 @@ import { 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'; export enum RegistrationSteps { Search = 'search', @@ -45,14 +52,18 @@ export type RegistrationContextProps = { setRegistrationStep: Dispatch>; selectedName: string; setSelectedName: Dispatch>; - registerNameTransactionHash: `0x${string}` | undefined; - setRegisterNameTransactionHash: Dispatch>; + 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; }; export const RegistrationContext = createContext({ @@ -72,18 +83,19 @@ 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 = { @@ -94,6 +106,9 @@ type RegistrationProviderProps = { export const registrationTransitionDuration = 'duration-700'; export default function RegistrationProvider({ children }: RegistrationProviderProps) { + // Wallet + const { address } = useAccount(); + // UI state const [searchInputFocused, setSearchInputFocused] = useState(false); const [searchInputHovered, setSearchInputHovered] = useState(false); @@ -107,19 +122,12 @@ export default function RegistrationProvider({ children }: RegistrationProviderP }, [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, - }); - // Discount code from URL const searchParams = useSearchParams(); const code = searchParams?.get('code'); @@ -138,41 +146,6 @@ 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}`; @@ -185,42 +158,71 @@ export default function RegistrationProvider({ children }: RegistrationProviderP router.push(profilePath); }, [profilePath, router]); - useEffect(() => { - if (transactionIsFetching && registrationStep === RegistrationSteps.Claim) { - logEventWithContext('register_name_transaction_processing', ActionType.change); - setRegistrationStep(RegistrationSteps.Pending); - } + const [years, setYears] = useState(1); - if (transactionIsSuccess && registrationStep === RegistrationSteps.Pending) { - if (transactionData.status === 'success') { - logEventWithContext('register_name_transaction_success', ActionType.change); - setRegistrationStep(RegistrationSteps.Success); - router.prefetch(profilePath); - } + // Has already registered with discount + const { data: hasRegisteredWithDiscount } = useReadContract({ + abi: RegistrarControllerABI, + address: USERNAME_REGISTRAR_CONTROLLER_ADDRESSES[basenameChain.id], + functionName: 'discountedRegistrants', + args: [address ?? zeroAddress], + }); - if (transactionData.status === 'reverted') { - logEventWithContext('register_name_transaction_reverted', ActionType.change, { - error: `Transaction reverted: ${transactionData.transactionHash}`, - }); - } - } - }, [ - baseEnsNameRefetch, - logEventWithContext, - profilePath, - registrationStep, - router, - transactionData, - transactionIsFetching, - transactionIsSuccess, - ]); + 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 (selectedName.length) { + if (registrationStep === RegistrationSteps.Search && selectedName.length) { setRegistrationStep(RegistrationSteps.Claim); } - }, [selectedName.length]); + }, [registrationStep, selectedName.length]); + + // transaction with paymaster + useEffect(() => { + if (batchCallsStatus === BatchCallsStatus.Approved) { + setRegistrationStep(RegistrationSteps.Pending); + } + if (batchCallsStatus === BatchCallsStatus.Success) { + setRegistrationStep(RegistrationSteps.Success); + } + }, [batchCallsStatus, setRegistrationStep]); + + // transaction without paymaster + useEffect(() => { + if (registerNameStatus === WriteTransactionWithReceiptStatus.Approved) { + setRegistrationStep(RegistrationSteps.Pending); + } + + if (registerNameStatus === WriteTransactionWithReceiptStatus.Success) { + setRegistrationStep(RegistrationSteps.Success); + } + }, [registerNameStatus, setRegistrationStep]); // On registration success with discount code: mark as consumed const hasRun = useRef(false); @@ -266,13 +268,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, @@ -283,27 +278,35 @@ export default function RegistrationProvider({ children }: RegistrationProviderP setSelectedName, registrationStep, setRegistrationStep, - registerNameTransactionHash, - setRegisterNameTransactionHash, redirectToProfile, loadingDiscounts, discount, allActiveDiscounts, - transactionData, - transactionError, + years, + setYears, + reverseRecord, + setReverseRecord, + hasExistingBasename, + registerNameIsPending, + registerNameError, + registerName, }; }, [ - allActiveDiscounts, - discount, - loadingDiscounts, - redirectToProfile, - registerNameTransactionHash, - registrationStep, searchInputFocused, searchInputHovered, selectedName, - transactionData, - transactionError, + registrationStep, + redirectToProfile, + loadingDiscounts, + discount, + allActiveDiscounts, + years, + reverseRecord, + setReverseRecord, + hasExistingBasename, + registerNameIsPending, + registerNameError, + registerName, ]); return {children}; diff --git a/apps/web/src/components/Basenames/RegistrationForm/index.tsx b/apps/web/src/components/Basenames/RegistrationForm/index.tsx index f9d24bdc29..0aefb03ce3 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,17 @@ export default function RegistrationForm() { ); const { - transactionData, - transactionError, selectedName, - setRegisterNameTransactionHash, discount, + years, + setYears, + reverseRecord, + setReverseRecord, + hasExistingBasename, + registerName, + registerNameError, + registerNameIsPending, } = useRegistration(); - const [years, setYears] = useState(1); const [premiumExplainerModalOpen, setPremiumExplainerModalOpen] = useState(false); const togglePremiumExplainerModal = useCallback(() => { @@ -96,13 +98,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 +132,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'); @@ -337,10 +316,9 @@ export default function RegistrationForm() { variant={ButtonVariants.Black} size={ButtonSizes.Medium} disabled={ - insufficientBalanceToRegisterAndCorrectChain || - registerNameTransactionIsPending + insufficientBalanceToRegisterAndCorrectChain || registerNameIsPending } - isLoading={registerNameTransactionIsPending} + isLoading={registerNameIsPending} rounded fullWidth > @@ -352,19 +330,10 @@ export default function RegistrationForm() { - {transactionError !== null && ( - - )} {registerNameError && ( )} - {transactionData && transactionData.status === 'reverted' && ( - - )} + {!IS_EARLY_ACCESS && (

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; From 88a2496ca3f563e614bc1ee904478f2ae3bf56da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Wed, 16 Oct 2024 15:54:10 -0400 Subject: [PATCH 13/17] refetch basename --- .../pages/api/proofs/discountCode/consume/index.ts | 1 - .../components/Basenames/RegistrationContext.tsx | 13 +++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/pages/api/proofs/discountCode/consume/index.ts b/apps/web/pages/api/proofs/discountCode/consume/index.ts index b70d9d0c0d..5148042a4e 100644 --- a/apps/web/pages/api/proofs/discountCode/consume/index.ts +++ b/apps/web/pages/api/proofs/discountCode/consume/index.ts @@ -23,7 +23,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return res.status(500).json({ error: 'Invalid request' }); } - // 1. get the database model await incrementDiscountCodeUsage(code); return res.status(200).json({ success: true }); diff --git a/apps/web/src/components/Basenames/RegistrationContext.tsx b/apps/web/src/components/Basenames/RegistrationContext.tsx index 3878b9ca3d..34d0890067 100644 --- a/apps/web/src/components/Basenames/RegistrationContext.tsx +++ b/apps/web/src/components/Basenames/RegistrationContext.tsx @@ -34,6 +34,7 @@ import { } 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'; export enum RegistrationSteps { Search = 'search', @@ -117,6 +118,11 @@ 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]); @@ -224,6 +230,13 @@ export default function RegistrationProvider({ children }: RegistrationProviderP } }, [registerNameStatus, setRegistrationStep]); + // Refetch name on success + useEffect(() => { + if (registrationStep === RegistrationSteps.Success) { + refetchBaseEnsName().catch((error) => logError(error, 'Failed to refetch Basename')); + } + }, [logError, refetchBaseEnsName, registrationStep]); + // On registration success with discount code: mark as consumed const hasRun = useRef(false); From 39f25bd093b42e016cb9e1520aa1110269d092f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Thu, 17 Oct 2024 11:17:31 -0400 Subject: [PATCH 14/17] fix: wrong name being used when reverseRecord is false --- .../Basenames/RegistrationContext.tsx | 14 +++++++++++-- .../RegistrationProfileForm/index.tsx | 21 +++++++------------ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/Basenames/RegistrationContext.tsx b/apps/web/src/components/Basenames/RegistrationContext.tsx index 34d0890067..eaff90b451 100644 --- a/apps/web/src/components/Basenames/RegistrationContext.tsx +++ b/apps/web/src/components/Basenames/RegistrationContext.tsx @@ -35,6 +35,7 @@ import { 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', @@ -53,6 +54,7 @@ export type RegistrationContextProps = { setRegistrationStep: Dispatch>; selectedName: string; setSelectedName: Dispatch>; + selectedNameFormatted: BaseName; years: number; setYears: Dispatch>; redirectToProfile: () => void; @@ -72,6 +74,7 @@ export const RegistrationContext = createContext({ searchInputHovered: false, registrationStep: RegistrationSteps.Search, selectedName: '', + selectedNameFormatted: '.base.eth', setSearchInputFocused: function () { return undefined; }, @@ -142,6 +145,11 @@ export default function RegistrationProvider({ children }: RegistrationProviderP 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( @@ -156,9 +164,9 @@ export default function RegistrationProvider({ children }: RegistrationProviderP 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(() => { router.push(profilePath); @@ -288,6 +296,7 @@ export default function RegistrationProvider({ children }: RegistrationProviderP setSearchInputFocused, setSearchInputHovered, selectedName, + selectedNameFormatted, setSelectedName, registrationStep, setRegistrationStep, @@ -308,6 +317,7 @@ export default function RegistrationProvider({ children }: RegistrationProviderP searchInputFocused, searchInputHovered, selectedName, + selectedNameFormatted, registrationStep, redirectToProfile, loadingDiscounts, 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 -

+
); From f4c4ca6b17fe044d0c9cb0338c10035e1d1f9ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Thu, 17 Oct 2024 13:27:03 -0400 Subject: [PATCH 15/17] prefetch --- apps/web/src/components/Basenames/RegistrationContext.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Basenames/RegistrationContext.tsx b/apps/web/src/components/Basenames/RegistrationContext.tsx index eaff90b451..683c398bb8 100644 --- a/apps/web/src/components/Basenames/RegistrationContext.tsx +++ b/apps/web/src/components/Basenames/RegistrationContext.tsx @@ -168,7 +168,7 @@ export default function RegistrationProvider({ children }: RegistrationProviderP } }, [basenameChain.id, selectedName, selectedNameFormatted]); - const redirectToProfile = useCallback(() => { + const redirectToProfile = useCallback(async () => { router.push(profilePath); }, [profilePath, router]); @@ -242,8 +242,9 @@ export default function RegistrationProvider({ children }: RegistrationProviderP useEffect(() => { if (registrationStep === RegistrationSteps.Success) { refetchBaseEnsName().catch((error) => logError(error, 'Failed to refetch Basename')); + router.prefetch(profilePath); } - }, [logError, refetchBaseEnsName, registrationStep]); + }, [logError, profilePath, refetchBaseEnsName, registrationStep, router]); // On registration success with discount code: mark as consumed const hasRun = useRef(false); From b107822f9f0c14a62166b65b396fbde2a57ad9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Fri, 18 Oct 2024 11:58:16 -0400 Subject: [PATCH 16/17] ssr code instead of search params --- .../names/RegistrationProviders.tsx | 10 ++++++-- apps/web/app/(basenames)/names/page.tsx | 24 +++++++++---------- .../pages/api/proofs/discountCode/index.ts | 1 + .../Basenames/RegistrationContext.tsx | 9 +++---- .../hooks/useAggregatedDiscountValidators.ts | 2 +- apps/web/src/hooks/useAttestations.ts | 2 +- 6 files changed, 26 insertions(+), 22 deletions(-) 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 4caa237719..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,19 +27,20 @@ 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/index.ts b/apps/web/pages/api/proofs/discountCode/index.ts index 9caac534cc..8dd21f3e8a 100644 --- a/apps/web/pages/api/proofs/discountCode/index.ts +++ b/apps/web/pages/api/proofs/discountCode/index.ts @@ -58,6 +58,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const signature = await signDiscountMessageWithTrustedSigner( address as Address, couponCodeUuid, + // TODO: Set variable chain USERNAME_DISCOUNT_CODE_VALIDATORS[baseSepolia.id], expirationTimeUnix, ); diff --git a/apps/web/src/components/Basenames/RegistrationContext.tsx b/apps/web/src/components/Basenames/RegistrationContext.tsx index 683c398bb8..36af6731cb 100644 --- a/apps/web/src/components/Basenames/RegistrationContext.tsx +++ b/apps/web/src/components/Basenames/RegistrationContext.tsx @@ -12,7 +12,7 @@ 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, useSearchParams } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import { Dispatch, ReactNode, @@ -104,12 +104,13 @@ export const RegistrationContext = createContext({ 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(); @@ -137,10 +138,6 @@ export default function RegistrationProvider({ children }: RegistrationProviderP const { logEventWithContext } = useAnalytics(); const { logError } = useErrors(); - // Discount code from URL - const searchParams = useSearchParams(); - const code = searchParams?.get('code'); - // Username discount states const { data: discounts, loading: loadingDiscounts } = useAggregatedDiscountValidators(code); const discount = findFirstValidDiscount(discounts); diff --git a/apps/web/src/hooks/useAggregatedDiscountValidators.ts b/apps/web/src/hooks/useAggregatedDiscountValidators.ts index f46eb35cab..15b241d348 100644 --- a/apps/web/src/hooks/useAggregatedDiscountValidators.ts +++ b/apps/web/src/hooks/useAggregatedDiscountValidators.ts @@ -38,7 +38,7 @@ export function findFirstValidDiscount( return sortedDiscounts.find((data) => data?.discountKey) ?? undefined; } -export function useAggregatedDiscountValidators(code: string | null | undefined) { +export function useAggregatedDiscountValidators(code?: string) { const { data: activeDiscountValidators, isLoading: loadingActiveDiscounts } = useActiveDiscountValidators(); const { data: CBIDData, loading: loadingCBIDAttestations } = useCheckCBIDAttestations(); diff --git a/apps/web/src/hooks/useAttestations.ts b/apps/web/src/hooks/useAttestations.ts index d008a1de14..a950bc9c5e 100644 --- a/apps/web/src/hooks/useAttestations.ts +++ b/apps/web/src/hooks/useAttestations.ts @@ -490,7 +490,7 @@ export function useBNSAttestations() { } // returns info about Discount Codes attestations -export function useDiscountCodeAttestations(code: string | null | undefined) { +export function useDiscountCodeAttestations(code?: string) { const { logError } = useErrors(); const { address } = useAccount(); const [loading, setLoading] = useState(false); From 7c853f9137a22fe747eb3c5a13e60e3d80d71c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Galley?= Date: Fri, 18 Oct 2024 15:47:33 -0400 Subject: [PATCH 17/17] add banner & code --- apps/web/src/addresses/usernames.ts | 2 +- .../web/src/components/Basenames/RegistrationContext.tsx | 3 +++ .../src/components/Basenames/RegistrationForm/index.tsx | 9 ++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/addresses/usernames.ts b/apps/web/src/addresses/usernames.ts index caf41c66a1..99bccbdef7 100644 --- a/apps/web/src/addresses/usernames.ts +++ b/apps/web/src/addresses/usernames.ts @@ -89,5 +89,5 @@ export const EXPONENTIAL_PREMIUM_PRICE_ORACLE: AddressMap = { export const USERNAME_DISCOUNT_CODE_VALIDATORS: AddressMap = { [baseSepolia.id]: '0x52acEeB464F600437a3681bEC087fb53F3f75638', - // [base.id]: '0x6E89d99643DB1223697C77A9F8B2Cb07E898e743', + [base.id]: '0x6F9A31238F502E9C9489274E59a44c967F4deC91', }; diff --git a/apps/web/src/components/Basenames/RegistrationContext.tsx b/apps/web/src/components/Basenames/RegistrationContext.tsx index 36af6731cb..c3153ce1b9 100644 --- a/apps/web/src/components/Basenames/RegistrationContext.tsx +++ b/apps/web/src/components/Basenames/RegistrationContext.tsx @@ -67,6 +67,7 @@ export type RegistrationContextProps = { registerNameIsPending: boolean; registerNameError: unknown; registerName: () => Promise; + code: string | undefined; }; export const RegistrationContext = createContext({ @@ -310,6 +311,7 @@ export default function RegistrationProvider({ children, code }: RegistrationPro registerNameIsPending, registerNameError, registerName, + code, }; }, [ searchInputFocused, @@ -328,6 +330,7 @@ export default function RegistrationProvider({ children, code }: RegistrationPro 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 0aefb03ce3..7267a9d4fa 100644 --- a/apps/web/src/components/Basenames/RegistrationForm/index.tsx +++ b/apps/web/src/components/Basenames/RegistrationForm/index.tsx @@ -80,6 +80,7 @@ export default function RegistrationForm() { registerName, registerNameError, registerNameIsPending, + code, } = useRegistration(); const [premiumExplainerModalOpen, setPremiumExplainerModalOpen] = useState(false); @@ -158,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, @@ -329,6 +330,12 @@ export default function RegistrationForm() {
+ {code && ( +
+ Claim your free creator basename — See you this{' '} + friday for dinner +
+ )} {registerNameError && (