diff --git a/docs/plans/2025-11-20-add-forward-resolving-to-referrer-parameter.md b/docs/plans/2025-11-20-add-forward-resolving-to-referrer-parameter.md new file mode 100644 index 000000000..165f5c78f --- /dev/null +++ b/docs/plans/2025-11-20-add-forward-resolving-to-referrer-parameter.md @@ -0,0 +1,991 @@ +# Add Forward Resolution to Referrer Parameter Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Enable the referrer parameter to accept ENS names and automatically resolve them to addresses using forward resolution (ENS name → ETH address, with fallback to owner/registrant). + +**Architecture:** Currently the referrer parameter only accepts hex values (addresses). This enhancement will add a utility function that performs forward resolution on ENS names: first attempting to get the ETH address record, then falling back to the owner/registrant address if no ETH address is set. The resolved address is then converted to the 32-byte hex format expected by smart contracts. + +**Resolution Flow:** +1. `referrer=vitalik.eth` (ENS name) +2. Try `getAddressRecord('vitalik.eth')` → if exists, use address +3. If no address record, try `getOwner('vitalik.eth')` → use owner/registrant +4. Convert address to 32-byte hex: `0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045` + +**Tech Stack:** +- **viem** - Ethereum address handling and hex conversion +- **@ensdomains/ensjs** - ENS resolution via `getAddressRecord` and `getOwner` +- **Vitest** - Unit testing +- **TypeScript** - Type safety with Address type + +--- + +## Task 1: Add ENS Name Forward Resolution Utility + +**Files:** +- Modify: `src/utils/referrer.ts` +- Test: `src/utils/referrer.test.ts` + +### Step 1: Write the failing test for ENS name forward resolution + +Add to `src/utils/referrer.test.ts` after line 64: + +```typescript +import { describe, expect, it, vi } from 'vitest' +import { Address } from 'viem' + +import { EMPTY_BYTES32 } from '@ensdomains/ensjs/utils' +import { getAddressRecord, getOwner } from '@ensdomains/ensjs/public' + +import { getReferrerHex, resolveReferrerToHex } from './referrer' + +// Mock ensjs functions +vi.mock('@ensdomains/ensjs/public', () => ({ + getAddressRecord: vi.fn(), + getOwner: vi.fn(), +})) + +describe('resolveReferrerToHex', () => { + const mockClient = {} as any + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should resolve ENS name to ETH address record and convert to hex', async () => { + const ensName = 'vitalik.eth' + const mockAddress = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Address + + vi.mocked(getAddressRecord).mockResolvedValueOnce({ + value: mockAddress, + coin: 60, // ETH + } as any) + + const result = await resolveReferrerToHex(mockClient, ensName) + + expect(result).toBeDefined() + expect(result?.length).toBe(66) // 32 bytes = 64 hex chars + '0x' + expect(result?.startsWith('0x')).toBe(true) + expect(vi.mocked(getAddressRecord)).toHaveBeenCalledWith(mockClient, { name: ensName }) + // Should not call getOwner if address record exists + expect(vi.mocked(getOwner)).not.toHaveBeenCalled() + }) + + it('should fall back to owner when no ETH address record exists', async () => { + const ensName = 'test.eth' + const mockOwnerAddress = '0x1234567890123456789012345678901234567890' as Address + + // No address record + vi.mocked(getAddressRecord).mockResolvedValueOnce(null) + + // Has owner + vi.mocked(getOwner).mockResolvedValueOnce({ + owner: mockOwnerAddress, + ownershipLevel: 'registrar', + } as any) + + const result = await resolveReferrerToHex(mockClient, ensName) + + expect(result).toBeDefined() + expect(result?.length).toBe(66) + expect(vi.mocked(getAddressRecord)).toHaveBeenCalledWith(mockClient, { name: ensName }) + expect(vi.mocked(getOwner)).toHaveBeenCalledWith(mockClient, { name: ensName }) + }) + + it('should return null when ENS name has neither address record nor owner', async () => { + vi.mocked(getAddressRecord).mockResolvedValueOnce(null) + vi.mocked(getOwner).mockResolvedValueOnce(null) + + const result = await resolveReferrerToHex(mockClient, 'nonexistent.eth') + expect(result).toBeNull() + }) + + it('should pass through valid hex addresses without resolution', async () => { + const hexAddress = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' + + const result = await resolveReferrerToHex(mockClient, hexAddress) + + // Should not call ENS resolution functions for hex addresses + expect(vi.mocked(getAddressRecord)).not.toHaveBeenCalled() + expect(vi.mocked(getOwner)).not.toHaveBeenCalled() + expect(result?.length).toBe(66) + }) + + it('should handle empty or undefined referrer', async () => { + const result1 = await resolveReferrerToHex(mockClient, undefined) + expect(result1).toBeNull() + + const result2 = await resolveReferrerToHex(mockClient, '') + expect(result2).toBeNull() + }) + + it('should handle resolution errors gracefully', async () => { + vi.mocked(getAddressRecord).mockRejectedValueOnce(new Error('Network error')) + + const result = await resolveReferrerToHex(mockClient, 'test.eth') + expect(result).toBeNull() + }) + + it('should handle owner with no address', async () => { + vi.mocked(getAddressRecord).mockResolvedValueOnce(null) + vi.mocked(getOwner).mockResolvedValueOnce({ + owner: undefined, + ownershipLevel: 'registrar', + } as any) + + const result = await resolveReferrerToHex(mockClient, 'test.eth') + expect(result).toBeNull() + }) +}) +``` + +**Expected behavior:** Tests should fail with "resolveReferrerToHex is not defined" + +### Step 2: Run test to verify it fails + +Run: `pnpm test src/utils/referrer.test.ts` + +Expected: FAIL with "resolveReferrerToHex is not exported from './referrer'" + +### Step 3: Implement ENS forward resolution function + +Add to `src/utils/referrer.ts` after the existing `getReferrerHex` function (after line 21): + +```typescript +import { Client } from 'viem' +import { getAddressRecord, getOwner } from '@ensdomains/ensjs/public' + +/** + * Resolves a referrer (ENS name or hex address) to a 32-byte hex value. + * + * Resolution flow for ENS names: + * 1. Try to get ETH address record from the name + * 2. If no address record, fall back to owner/registrant address + * 3. Convert the resolved address to 32-byte hex + * + * @param client - Viem client for blockchain queries + * @param referrer - ENS name (e.g., 'vitalik.eth') or hex address + * @returns 32-byte hex string or null if resolution fails + */ +export const resolveReferrerToHex = async ( + client: Client, + referrer: string | undefined, +): Promise<`0x${string}` | null> => { + if (!referrer) return null + + // If it's already a valid hex string, just pad it + if (isHex(referrer)) { + const paddedHex = getReferrerHex(referrer) + return paddedHex === EMPTY_BYTES32 ? null : paddedHex + } + + // Try to resolve as ENS name + try { + // Step 1: Try to get ETH address record + const addressRecord = await getAddressRecord(client, { name: referrer }) + + if (addressRecord?.value) { + // Found address record, convert to hex + return getReferrerHex(addressRecord.value) + } + + // Step 2: Fall back to owner/registrant + const ownerData = await getOwner(client, { name: referrer }) + + if (ownerData?.owner) { + // Found owner, convert to hex + return getReferrerHex(ownerData.owner) + } + + // No address record and no owner + return null + } catch (error) { + console.error('Failed to resolve referrer ENS name:', error) + return null + } +} +``` + +**Note:** Need to update imports at top of file: + +```typescript +import { Client, isHex, pad } from 'viem' +import { getAddressRecord, getOwner } from '@ensdomains/ensjs/public' +import { EMPTY_BYTES32 } from '@ensdomains/ensjs/utils' +``` + +### Step 4: Run test to verify it passes + +Run: `pnpm test src/utils/referrer.test.ts` + +Expected: All tests should pass + +### Step 5: Add import statement to test file + +Make sure test file has proper imports: + +```typescript +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Address } from 'viem' + +import { EMPTY_BYTES32 } from '@ensdomains/ensjs/utils' +import { getAddressRecord, getOwner } from '@ensdomains/ensjs/public' + +import { getReferrerHex, resolveReferrerToHex } from './referrer' +``` + +### Step 6: Run tests again to confirm + +Run: `pnpm test src/utils/referrer.test.ts` + +Expected: All tests pass + +### Step 7: Commit utility function + +```bash +git add src/utils/referrer.ts src/utils/referrer.test.ts +git commit -m "feat: add ENS forward resolution for referrer parameter + +- Resolves ENS names to addresses (address record → owner fallback) +- Converts resolved addresses to 32-byte hex format +- Handles invalid names and resolution errors gracefully" +``` + +--- + +## Task 2: Create Hook for Referrer Resolution + +**Files:** +- Create: `src/hooks/useResolvedReferrer.ts` +- Create: `src/hooks/useResolvedReferrer.test.tsx` + +### Step 1: Write the failing test for the hook + +Create `src/hooks/useResolvedReferrer.test.tsx`: + +```typescript +import { renderHook, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { Address } from 'viem' + +import { resolveReferrerToHex } from '@app/utils/referrer' + +vi.mock('@app/utils/referrer', () => ({ + resolveReferrerToHex: vi.fn(), +})) + +vi.mock('wagmi', () => ({ + usePublicClient: vi.fn(() => ({ chain: { id: 1 } })), +})) + +import { useResolvedReferrer } from './useResolvedReferrer' + +describe('useResolvedReferrer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return null when no referrer provided', () => { + const { result } = renderHook(() => useResolvedReferrer(undefined)) + + expect(result.current.data).toBeNull() + expect(result.current.isLoading).toBe(false) + }) + + it('should resolve ENS name to hex', async () => { + const mockHex = '0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045' as Address + vi.mocked(resolveReferrerToHex).mockResolvedValueOnce(mockHex) + + const { result } = renderHook(() => useResolvedReferrer('vitalik.eth')) + + await waitFor(() => { + expect(result.current.data).toBe(mockHex) + expect(result.current.isLoading).toBe(false) + }) + + expect(vi.mocked(resolveReferrerToHex)).toHaveBeenCalledWith( + expect.anything(), + 'vitalik.eth' + ) + }) + + it('should handle resolution errors gracefully', async () => { + vi.mocked(resolveReferrerToHex).mockRejectedValueOnce(new Error('Resolution failed')) + + const { result } = renderHook(() => useResolvedReferrer('invalid.eth')) + + await waitFor(() => { + expect(result.current.data).toBeNull() + expect(result.current.isError).toBe(true) + }) + }) + + it('should handle null resolution result', async () => { + vi.mocked(resolveReferrerToHex).mockResolvedValueOnce(null) + + const { result } = renderHook(() => useResolvedReferrer('nonexistent.eth')) + + await waitFor(() => { + expect(result.current.data).toBeNull() + expect(result.current.isLoading).toBe(false) + }) + }) + + it('should not query when client is unavailable', () => { + // Mock missing client + vi.mocked(require('wagmi').usePublicClient).mockReturnValueOnce(undefined) + + const { result } = renderHook(() => useResolvedReferrer('vitalik.eth')) + + expect(result.current.isLoading).toBe(false) + expect(vi.mocked(resolveReferrerToHex)).not.toHaveBeenCalled() + }) +}) +``` + +**Expected behavior:** Test fails with "useResolvedReferrer is not defined" + +### Step 2: Run test to verify it fails + +Run: `pnpm test src/hooks/useResolvedReferrer.test.tsx` + +Expected: FAIL with "Cannot find module './useResolvedReferrer'" + +### Step 3: Implement the hook + +Create `src/hooks/useResolvedReferrer.ts`: + +```typescript +import { useQuery } from '@tanstack/react-query' +import { Hex } from 'viem' +import { usePublicClient } from 'wagmi' + +import { resolveReferrerToHex } from '@app/utils/referrer' + +type UseResolvedReferrerResult = { + data: Hex | null + isLoading: boolean + isError: boolean + error: Error | null +} + +/** + * Hook to resolve a referrer (ENS name or hex address) to a 32-byte hex value. + * + * For ENS names, attempts to: + * 1. Get ETH address record from the name + * 2. Fall back to owner/registrant if no address record + * 3. Convert resolved address to 32-byte hex + * + * @param referrer - ENS name or hex address to resolve + * @returns Query result with resolved hex value + */ +export const useResolvedReferrer = ( + referrer: string | undefined, +): UseResolvedReferrerResult => { + const publicClient = usePublicClient() + + const query = useQuery({ + queryKey: ['resolved-referrer', referrer], + queryFn: async () => { + if (!publicClient || !referrer) return null + return resolveReferrerToHex(publicClient, referrer) + }, + enabled: !!referrer && !!publicClient, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 10, // 10 minutes + }) + + return { + data: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + } +} +``` + +### Step 4: Run test to verify it passes + +Run: `pnpm test src/hooks/useResolvedReferrer.test.tsx` + +Expected: Tests should pass + +### Step 5: Commit the hook + +```bash +git add src/hooks/useResolvedReferrer.ts src/hooks/useResolvedReferrer.test.tsx +git commit -m "feat: add useResolvedReferrer hook + +- React hook wrapping resolveReferrerToHex utility +- Uses React Query for caching with 5min stale time +- Returns loading/error states for UI integration" +``` + +--- + +## Task 3: Update ExtendNames Flow to Use Resolved Referrer + +**Files:** +- Modify: `src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx` +- Modify: `src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx` + +### Step 1: Write failing test for resolved referrer in ExtendNames + +Add to `src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx` at the appropriate location: + +```typescript +import { useResolvedReferrer } from '@app/hooks/useResolvedReferrer' + +// Add mock for useResolvedReferrer +vi.mock('@app/hooks/useResolvedReferrer', () => ({ + useResolvedReferrer: vi.fn(() => ({ + data: null, + isLoading: false, + isError: false, + error: null, + })), +})) + +// Add test case +it('should resolve ENS name referrer to hex', async () => { + const mockReferrerHex = '0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045' as Hex + + // Mock useReferrer to return an ENS name + vi.mocked(useReferrer).mockReturnValue('vitalik.eth') + + // Mock useResolvedReferrer to return resolved hex + vi.mocked(useResolvedReferrer).mockReturnValue({ + data: mockReferrerHex, + isLoading: false, + isError: false, + error: null, + }) + + // Render and test component + // Specific implementation depends on existing test structure + // Verify that the transaction uses the resolved hex value +}) + +it('should handle failed referrer resolution gracefully', async () => { + vi.mocked(useReferrer).mockReturnValue('invalid.eth') + vi.mocked(useResolvedReferrer).mockReturnValue({ + data: null, + isLoading: false, + isError: true, + error: new Error('Resolution failed'), + }) + + // Should continue with transaction flow, just without referrer + // Specific test implementation depends on structure +}) +``` + +**Expected behavior:** Test fails because component doesn't use `useResolvedReferrer` yet + +### Step 2: Run test to verify it fails + +Run: `pnpm test src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx` + +Expected: FAIL - test assertion fails or useResolvedReferrer not imported + +### Step 3: Update ExtendNames-flow.tsx to use resolved referrer + +In `src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx`, add import (around line 22): + +```typescript +import { useResolvedReferrer } from '@app/hooks/useResolvedReferrer' +``` + +Remove the import for `getReferrerHex` (no longer needed directly): + +```typescript +// Remove or comment out: +// import { getReferrerHex } from '@app/utils/referrer' +``` + +Then update the component to use the resolved referrer. Find where `useReferrer` is called and update: + +```typescript +export const ExtendNamesFlow = ({ /* props */ }: Props) => { + // ... existing code ... + + const referrerParam = useReferrer() + const { data: referrerHex, isLoading: isReferrerResolving } = useResolvedReferrer(referrerParam) + + // ... rest of the code ... +} +``` + +Update the loading check to include referrer resolution (find `isBaseDataLoading`): + +```typescript +const isBaseDataLoading = + !isAccountConnected || + isBalanceLoading || + isExpiryEnabledAndLoading || + isEthPriceLoading || + isReferrerResolving +``` + +Make sure `referrerHex` is passed to the transaction (should already be in place around line 327): + +```typescript +const transactions = createTransactionItem('extendNames', { + names, + duration: seconds, + startDateTimestamp: expiryDate?.getTime(), + displayPrice: makeCurrencyDisplay({ + eth: totalRentFee, + ethPrice, + bufferPercentage: CURRENCY_FLUCTUATION_BUFFER_PERCENTAGE, + currency: userConfig.currency === 'fiat' ? 'usd' : 'eth', + }), + referrer: referrerHex, + hasWrapped, +}) +``` + +### Step 4: Run test to verify it passes + +Run: `pnpm test src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx` + +Expected: Tests pass + +### Step 5: Commit the changes + +```bash +git add src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx +git commit -m "feat: integrate ENS forward resolution in ExtendNames flow + +- Use useResolvedReferrer hook to resolve ENS names +- Add loading state for referrer resolution +- Remove direct use of getReferrerHex utility" +``` + +--- + +## Task 4: Update Registration Flow to Use Resolved Referrer + +**Files:** +- Modify: `src/components/pages/profile/[name]/registration/Registration.tsx` + +### Step 1: Identify where referrer is used in Registration + +Read the Registration component to understand current referrer usage: + +Run: `cat src/components/pages/profile/[name]/registration/Registration.tsx | grep -A5 -B5 referrer` + +### Step 2: Add import for useResolvedReferrer + +In `src/components/pages/profile/[name]/registration/Registration.tsx`, add import: + +```typescript +import { useResolvedReferrer } from '@app/hooks/useResolvedReferrer' +``` + +### Step 3: Update Registration.tsx to use resolved referrer + +Update component to resolve referrer. Find where `useReferrer()` is called and update: + +```typescript +export const Registration = ({ /* props */ }) => { + // ... existing code ... + + const referrerParam = useReferrer() + const { data: resolvedReferrerHex } = useResolvedReferrer(referrerParam) + + // Update referrer in registration data when resolved + useEffect(() => { + if (resolvedReferrerHex && selectedItemProperties) { + dispatch({ + name: 'setReferrer', + selected: selectedItemProperties, + payload: resolvedReferrerHex, + }) + } + }, [resolvedReferrerHex, selectedItemProperties, dispatch]) + + // ... rest of code ... +} +``` + +Make sure to add React import if not present: + +```typescript +import { useEffect } from 'react' +``` + +### Step 4: Run type checking + +Run: `pnpm lint:types` + +Expected: No type errors + +### Step 5: Commit changes + +```bash +git add src/components/pages/profile/[name]/registration/Registration.tsx +git commit -m "feat: integrate ENS forward resolution in registration flow + +- Use useResolvedReferrer to resolve ENS names to addresses +- Update registration reducer with resolved hex value +- Automatic resolution on component mount and when referrer changes" +``` + +--- + +## Task 5: Update DesyncedMessage Component (if needed) + +**Files:** +- Check: `src/components/@molecules/DesyncedMessage/DesyncedMessage.tsx` + +### Step 1: Check if DesyncedMessage uses getReferrerHex + +Run: `cat src/components/@molecules/DesyncedMessage/DesyncedMessage.tsx | grep -C5 getReferrerHex` + +### Step 2: Update if it uses referrer + +If it uses `getReferrerHex`, update to use `useResolvedReferrer` hook instead. + +Follow same pattern as ExtendNames flow: +- Add import for `useResolvedReferrer` +- Replace `getReferrerHex(referrerParam)` with hook usage +- Add loading state if needed + +### Step 3: Run tests + +Run: `pnpm test src/components/@molecules/DesyncedMessage/` + +Expected: Tests pass + +### Step 4: Commit if changes made + +```bash +git add src/components/@molecules/DesyncedMessage/DesyncedMessage.tsx +git commit -m "feat: update DesyncedMessage to use resolved referrer" +``` + +--- + +## Task 6: Add Integration Tests + +**Files:** +- Modify: `e2e/specs/stateless/extendNames.spec.ts` + +### Step 1: Add E2E test for ENS name referrer in extend names + +Add to `e2e/specs/stateless/extendNames.spec.ts`: + +```typescript +test('should accept ENS name as referrer parameter for extending', async ({ page, login, makeName }) => { + const name = await makeName({ + label: 'extend', + type: 'legacy', + }) + + // Navigate with ENS name referrer + await page.goto(`/${name}?referrer=vitalik.eth`) + await login.connect() + + // Start extend flow + await page.click('[data-testid="extend-button"]') + + // Verify the flow continues without error + await expect(page.locator('[data-testid="extend-names-modal"]')).toBeVisible() + + // The referrer resolution should happen in background + // Transaction should be created with resolved hex value +}) +``` + +### Step 2: Add E2E test for hex referrer still working + +```typescript +test('should still accept hex address as referrer for extending', async ({ page, login, makeName }) => { + const name = await makeName({ + label: 'extend-hex', + type: 'legacy', + }) + + await page.goto(`/${name}?referrer=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045`) + await login.connect() + + await page.click('[data-testid="extend-button"]') + await expect(page.locator('[data-testid="extend-names-modal"]')).toBeVisible() +}) +``` + +### Step 3: Add E2E test for registration with ENS referrer + +Modify `e2e/specs/stateless/registerName.spec.ts`: + +```typescript +test('should register with ENS name referrer', async ({ page, login }) => { + const label = `registration-referrer-${Date.now()}` + + await page.goto(`/${label}.eth?referrer=vitalik.eth`) + await login.connect() + + // Start registration + // ... existing registration flow ... + + // Verify registration completes successfully +}) +``` + +### Step 4: Run E2E tests + +Run: `pnpm denv` (start local environment) +Run: `pnpm e2e e2e/specs/stateless/extendNames.spec.ts` +Run: `pnpm e2e e2e/specs/stateless/registerName.spec.ts` + +Expected: Tests pass + +### Step 5: Commit E2E tests + +```bash +git add e2e/specs/stateless/extendNames.spec.ts e2e/specs/stateless/registerName.spec.ts +git commit -m "test: add E2E tests for ENS name referrer resolution" +``` + +--- + +## Task 7: Update Documentation + +**Files:** +- Modify: `README.md` or create docs file + +### Step 1: Document referrer parameter enhancement + +Add documentation section about referrer parameter: + +```markdown +## Referrer Parameter + +The `referrer` URL parameter supports both hex addresses and ENS names: + +### Usage Examples + +**Hex Address:** +``` +?referrer=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +``` + +**ENS Name:** +``` +?referrer=vitalik.eth +``` + +### Resolution Logic + +When an ENS name is provided: + +1. **Address Record Lookup**: First attempts to get the ETH address record set on the name +2. **Owner Fallback**: If no address record exists, falls back to the name's owner/registrant address +3. **Hex Conversion**: Converts the resolved address to a 32-byte hex value for the contract + +If resolution fails at any step, the referrer parameter is ignored and the transaction proceeds normally. + +### Technical Details + +- Resolution is cached for 5 minutes to improve performance +- Invalid referrers (non-hex, non-ENS) are silently ignored +- Resolution happens asynchronously without blocking the UI +``` + +### Step 2: Commit documentation + +```bash +git add README.md +git commit -m "docs: document ENS name support for referrer parameter" +``` + +--- + +## Task 8: Run Full Test Suite and Type Check + +### Step 1: Run type checking + +Run: `pnpm lint:types` + +Expected: No type errors + +### Step 2: Run all unit tests + +Run: `pnpm test` + +Expected: All tests pass + +### Step 3: Run linting + +Run: `pnpm lint` + +Expected: No lint errors + +### Step 4: Fix any issues found + +If there are type errors, test failures, or lint issues: +- Review error messages carefully +- Fix issues one by one +- Re-run checks after each fix + +### Step 5: Commit fixes if any + +```bash +git add . +git commit -m "fix: address type errors and test failures" +``` + +--- + +## Task 9: Manual Testing + +### Step 1: Start local development environment + +Run: `pnpm denv` + +Wait for environment to be ready, then in another terminal: + +Run: `pnpm dev:glocal` + +### Step 2: Test ENS name referrer in registration + +1. Navigate to `localhost:3000/test-name-12345.eth?referrer=vitalik.eth` +2. Connect wallet +3. Start registration process +4. Open browser dev tools → Network tab +5. Verify referrer resolution request +6. Complete registration +7. Verify transaction includes resolved referrer hex + +### Step 3: Test ENS name with no address record + +1. Create or find ENS name with no address record +2. Navigate to registration with that name as referrer +3. Verify it falls back to owner address +4. Verify registration completes + +### Step 4: Test hex address referrer still works + +1. Navigate to `localhost:3000/test-name-67890.eth?referrer=0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045` +2. Start registration +3. Verify hex referrer works without resolution attempt +4. Complete registration + +### Step 5: Test extend names with ENS referrer + +1. Create a name that's already registered +2. Navigate to `localhost:3000/?referrer=vitalik.eth` +3. Click extend button +4. Verify ENS name is resolved +5. Complete extension + +### Step 6: Test invalid referrer handling + +1. Navigate to `localhost:3000/test-invalid.eth?referrer=this-does-not-exist.eth` +2. Start registration +3. Verify flow continues without error +4. Verify no referrer is included in transaction + +--- + +## Task 10: Final Review and Cleanup + +### Step 1: Review all changes + +Run: `git diff main` + +Review all modifications to ensure: +- Code follows DRY principles (reuses `getReferrerHex` utility) +- No unnecessary code added (YAGNI - only ENS resolution, no extra features) +- Tests cover edge cases (address record, owner fallback, errors) +- TypeScript types are correct +- No console.logs or debug code left (except intentional error logging) + +### Step 2: Check for unused imports + +Run: `pnpm lint` + +Fix any unused import warnings. + +### Step 3: Run full build + +Run: `pnpm build` + +Expected: Build succeeds without errors or warnings + +### Step 4: Create final cleanup commit if needed + +```bash +git add . +git commit -m "chore: final cleanup for referrer resolution feature" +``` + +### Step 5: Verify git log + +Run: `git log --oneline main..HEAD` + +Review commit history: +- Each commit should be atomic +- Commit messages should be clear +- No WIP or temporary commits + +--- + +## Verification Checklist + +Before marking this feature complete, verify: + +- ✅ Unit tests pass for `resolveReferrerToHex` utility + - ✅ Address record resolution + - ✅ Owner fallback + - ✅ Hex passthrough + - ✅ Error handling +- ✅ Unit tests pass for `useResolvedReferrer` hook +- ✅ ExtendNames flow resolves ENS names +- ✅ Registration flow resolves ENS names +- ✅ Hex addresses still work without resolution +- ✅ Invalid referrers handled gracefully +- ✅ Type checking passes (`pnpm lint:types`) +- ✅ All tests pass (`pnpm test`) +- ✅ E2E tests pass +- ✅ Build succeeds (`pnpm build`) +- ✅ Manual testing confirms: + - ✅ ENS name → address record works + - ✅ ENS name → owner fallback works + - ✅ Hex addresses work + - ✅ Invalid names handled +- ✅ Documentation updated + +--- + +## Notes + +**DRY (Don't Repeat Yourself):** +- Reuse `getReferrerHex` for hex padding in all cases +- Single `resolveReferrerToHex` function used by all flows +- Hook wraps utility for React integration + +**YAGNI (You Aren't Gonna Need It):** +- Only implement ENS → Address resolution (address record + owner fallback) +- Don't add complex caching beyond React Query defaults +- Don't add UI loading indicators unless explicitly needed +- Don't add logging beyond error cases + +**TDD (Test-Driven Development):** +- Each task follows RED (write test) → GREEN (make it pass) → REFACTOR pattern +- Tests written before implementation +- Edge cases covered in tests + +**Frequent Commits:** +- Commit after each task completes +- Clear, descriptive commit messages following conventional commits +- Atomic commits for easy review and rollback if needed + +**Forward Resolution Flow:** +1. Input: ENS name (e.g., `vitalik.eth`) +2. Try `getAddressRecord` → if has address, use it +3. If no address, try `getOwner` → use owner address +4. Convert address to 32-byte hex +5. Output: `0x000...000
` diff --git a/e2e/specs/stateless/extendNames.spec.ts b/e2e/specs/stateless/extendNames.spec.ts index 033cd2eb4..dcc32aa77 100644 --- a/e2e/specs/stateless/extendNames.spec.ts +++ b/e2e/specs/stateless/extendNames.spec.ts @@ -775,6 +775,7 @@ test.describe('Legacy/Unwrapped Name Extension with Referrer', () => { const profilePage = makePageObject('ProfilePage') const transactionModal = makePageObject('TransactionModal') + const extendNamesModal = makePageObject('ExtendNamesModal') await page.goto(`/${name}?referrer=${referrerAddress}`) await login.connect() @@ -787,12 +788,12 @@ test.describe('Legacy/Unwrapped Name Extension with Referrer', () => { // Set extension and proceed await expect(page.getByTestId('plus-minus-control-label')).toHaveText('1 year') - await page.locator('button:has-text("Next")').click() + await extendNamesModal.getExtendButton.click() // Complete transaction await transactionModal.confirm() - await expect(page.getByText('Your "Extend Names" transaction was successful')).toBeVisible({ + await expect(page.getByText('Your "Extend names" transaction was successful')).toBeVisible({ timeout: 10000, }) @@ -817,6 +818,7 @@ test.describe('Legacy/Unwrapped Name Extension with Referrer', () => { const profilePage = makePageObject('ProfilePage') const transactionModal = makePageObject('TransactionModal') + const extendNamesModal = makePageObject('ExtendNamesModal') await page.goto(`/${name}`) await login.connect() @@ -829,7 +831,7 @@ test.describe('Legacy/Unwrapped Name Extension with Referrer', () => { // Set extension and proceed await expect(page.getByTestId('plus-minus-control-label')).toHaveText('1 year') - await page.locator('button:has-text("Next")').click() + await extendNamesModal.getExtendButton.click() // Complete transaction await transactionModal.confirm() @@ -856,6 +858,7 @@ test.describe('Legacy/Unwrapped Name Extension with Referrer', () => { const profilePage = makePageObject('ProfilePage') const transactionModal = makePageObject('TransactionModal') + const extendNamesModal = makePageObject('ExtendNamesModal') await profilePage.goto(name) await login.connect() @@ -871,7 +874,7 @@ test.describe('Legacy/Unwrapped Name Extension with Referrer', () => { // Set extension and proceed await expect(page.getByTestId('plus-minus-control-label')).toHaveText('1 year') - await page.locator('button:has-text("Next")').click() + await extendNamesModal.getExtendButton.click() // Complete transaction await transactionModal.confirm() @@ -885,6 +888,66 @@ test.describe('Legacy/Unwrapped Name Extension with Referrer', () => { const referrerHex = addressToBytes32(referrerAddress) expect(latestTransaction.input).toContain(referrerHex.slice(2)) // Remove '0x' prefix for comparison }) + + test('should extend unwrapped name with ENS name as referrer', async ({ + page, + login, + accounts, + makeName, + makePageObject, + }) => { + // Create an ENS name that will be used as referrer (has an ETH address record) + const referrerName = await makeName({ + label: 'legacy-ens-referrer-name', + type: 'legacy', + owner: 'user2', + records: { + coins: [ + { + coin: 'ETH', + value: accounts.getAddress('user2'), + }, + ], + }, + }) + + const name = await makeName({ + label: 'legacy-with-ens-referrer', + type: 'legacy', + owner: 'user', + }) + + const profilePage = makePageObject('ProfilePage') + const transactionModal = makePageObject('TransactionModal') + const extendNamesModal = makePageObject('ExtendNamesModal') + + await page.goto(`/${name}?referrer=${referrerName}`) + await login.connect() + + // Verify referrer is in URL + expect(page.url()).toContain(`referrer=${referrerName}`) + + // Click extend button + await profilePage.getExtendButton.click() + + // Set extension and proceed + await page.getByTestId('plus-minus-control-plus').click() + // Wait for referrer to be resolved before clicking + await expect(extendNamesModal.getExtendButton).toBeEnabled({ timeout: 10000 }) + await extendNamesModal.getExtendButton.click() + + await transactionModal.confirm() + + await expect(page.getByText('Your "Extend names" transaction was successful')).toBeVisible({ + timeout: 10000, + }) + + // Verify resolved referrer address in transaction calldata + const latestTransaction = await publicClient.getTransaction({ blockTag: 'latest', index: 0 }) + // The ENS name should have resolved to user2's address + const referrerHex = addressToBytes32(accounts.getAddress('user2')) + expect(latestTransaction.input).toContain(referrerHex.slice(2)) // Remove '0x' prefix for comparison + }) }) test.describe('Wrapped Name Renewal with Referrer', () => { @@ -903,6 +966,7 @@ test.describe('Wrapped Name Renewal with Referrer', () => { }) const transactionModal = makePageObject('TransactionModal') + const extendNamesModal = makePageObject('ExtendNamesModal') // Add referrer parameter to URL const referrerAddress = '0x1234567890123456789012345678901234567890' @@ -917,7 +981,7 @@ test.describe('Wrapped Name Renewal with Referrer', () => { await expect(page.getByTestId('plus-minus-control-label')).toHaveText('1 year') // Proceed to transaction - await page.locator('button:has-text("Next")').click() + await extendNamesModal.getExtendButton.click() // Complete transaction await transactionModal.confirm() @@ -947,6 +1011,7 @@ test.describe('Wrapped Name Renewal with Referrer', () => { }) const transactionModal = makePageObject('TransactionModal') + const extendNamesModal = makePageObject('ExtendNamesModal') const referrerAddress = '0x1234567890123456789012345678901234567890' // Start on profile page with referrer @@ -982,7 +1047,7 @@ test.describe('Wrapped Name Renewal with Referrer', () => { await expect(page.getByTestId('plus-minus-control-label')).toHaveText('2 years') // Proceed - await page.locator('button:has-text("Next")').click() + await extendNamesModal.getExtendButton.click() // Verify referrer persisted expect(page.url()).toContain(`referrer=${referrerAddress}`) @@ -1009,6 +1074,7 @@ test.describe('Wrapped Name Renewal with Referrer', () => { }) const transactionModal = makePageObject('TransactionModal') + const extendNamesModal = makePageObject('ExtendNamesModal') // Navigate without referrer parameter await page.goto(`/${name}`) @@ -1018,7 +1084,7 @@ test.describe('Wrapped Name Renewal with Referrer', () => { await expect(page.getByTestId('plus-minus-control-label')).toHaveText('1 year') - await page.locator('button:has-text("Next")').click() + await extendNamesModal.getExtendButton.click() await transactionModal.confirm() @@ -1028,6 +1094,68 @@ test.describe('Wrapped Name Renewal with Referrer', () => { }) }) + test('should extend wrapped name with ENS name as referrer', async ({ + page, + login, + accounts, + makeName, + makePageObject, + }) => { + // Create an ENS name that will be used as referrer (has an ETH address record) + const referrerName = await makeName({ + label: 'extend-referrer', + type: 'legacy', + owner: 'user2', + records: { + coins: [ + { + coin: 'ETH', + value: accounts.getAddress('user2'), + }, + ], + }, + }) + + const name = await makeName({ + label: 'wrapped-ens-referrer', + type: 'wrapped', + owner: 'user', + duration: daysToSeconds(30), + }) + + const transactionModal = makePageObject('TransactionModal') + const extendNamesModal = makePageObject('ExtendNamesModal') + + await page.goto(`/${name}?referrer=${referrerName}`) + await login.connect() + + // Verify referrer is in URL + expect(page.url()).toContain(`referrer=${referrerName}`) + + // Click extend button + await page.getByTestId('extend-button').click() + + // Set extension and proceed + await page.getByTestId('plus-minus-control-plus').click() + await expect(page.getByTestId('plus-minus-control-label')).toHaveText('2 years') + + // Wait for referrer to be resolved before clicking + await expect(extendNamesModal.getExtendButton).toBeEnabled({ timeout: 10000 }) + await extendNamesModal.getExtendButton.click() + + await transactionModal.confirm() + + await expect(page.getByText('Your "Extend names" transaction was successful')).toBeVisible({ + timeout: 10000, + }) + + // Verify resolved referrer address in transaction calldata + const latestTransaction = await publicClient.getTransaction({ blockTag: 'latest', index: 0 }) + // The ENS name should have resolved to user2's address + const referrerHex = addressToBytes32(accounts.getAddress('user2')) + expect(latestTransaction.input).toContain(referrerHex.slice(2)) // Remove '0x' prefix for comparison + }) + test('should use correct contract for wrapped vs unwrapped names', async ({ page, login, @@ -1050,12 +1178,13 @@ test.describe('Wrapped Name Renewal with Referrer', () => { }) const transactionModal = makePageObject('TransactionModal') + const extendNamesModal = makePageObject('ExtendNamesModal') // Test wrapped name renewal await page.goto(`/${wrappedName}`) await login.connect() await page.getByTestId('extend-button').click() - await page.locator('button:has-text("Next")').click() + await extendNamesModal.getExtendButton.click() // Note: In a real test, we would inspect the transaction data to verify // it's calling the correct contract (UniversalRegistrarRenewalWithReferrer) @@ -1068,7 +1197,7 @@ test.describe('Wrapped Name Renewal with Referrer', () => { // Test legacy name renewal await page.goto(`/${legacyName}`) await page.getByTestId('extend-button').click() - await page.locator('button:has-text("Next")').click() + await extendNamesModal.getExtendButton.click() // Note: This should use the standard ETHRegistrarController await transactionModal.confirm() @@ -1110,6 +1239,7 @@ test.describe('Wrapped Name Renewal with Referrer', () => { const address = accounts.getAddress('user') const addressPage = makePageObject('AddressPage') const transactionModal = makePageObject('TransactionModal') + const extendNamesModal = makePageObject('ExtendNamesModal') await page.goto(`/my/names?address=${address}`) await login.connect() @@ -1134,7 +1264,7 @@ test.describe('Wrapped Name Renewal with Referrer', () => { // Check that we're extending at least 3 names (the ones we created) await expect(page.getByText(/Extend 3 Names/)).toBeVisible() - await page.locator('button:has-text("Next")').click() + await extendNamesModal.getExtendButton.click() // Complete transaction await transactionModal.confirm() diff --git a/e2e/specs/stateless/registerName.spec.ts b/e2e/specs/stateless/registerName.spec.ts index d3b07730a..71f05d525 100644 --- a/e2e/specs/stateless/registerName.spec.ts +++ b/e2e/specs/stateless/registerName.spec.ts @@ -1953,6 +1953,77 @@ test.describe('Registration with Referrer', () => { }) }) }) + + test('should register a name with ENS name as referrer', async ({ + page, + login, + accounts, + time, + makeName, + makePageObject, + }) => { + await time.sync(500) + + // Create an ENS name that will be used as referrer (has an ETH address record) + const referrerName = await makeName({ + label: 'referrer', + type: 'legacy', + owner: 'user2', + records: { + coins: [ + { + coin: 'ETH', + value: accounts.getAddress('user2'), + }, + ], + }, + }) + + const name = `registration-with-ens-referrer-${Date.now()}.eth` + const transactionModal = makePageObject('TransactionModal') + + await test.step('should navigate to registration page with ENS name as referrer', async () => { + await page.goto(`/${name}/register?referrer=${referrerName}`) + await login.connect() + + // Verify referrer is in URL + expect(page.url()).toContain(`referrer=${referrerName}`) + }) + + await test.step('should complete pricing step', async () => { + await page.getByTestId('payment-choice-ethereum').check() + await page.getByTestId('primary-name-toggle').uncheck() + await page.getByTestId('next-button').click() + }) + + await test.step('should go to info step', async () => { + // Skip profile editor - click next to go to info step + await page.getByTestId('next-button').click() + }) + + await test.step('should complete commit transaction', async () => { + await page.getByTestId('next-button').click() + await transactionModal.confirm() + await expect(page.getByTestId('countdown-complete-check')).toBeVisible({ timeout: 10000 }) + await testClient.increaseTime({ seconds: 60 }) + }) + + await test.step('should complete register transaction', async () => { + await page.getByTestId('finish-button').click() + await transactionModal.confirm() + + await expect(page.getByText('Your "Register name" transaction was successful')).toBeVisible({ + timeout: 10000, + }) + }) + + await test.step('should verify resolved referrer address in transaction calldata', async () => { + const latestTransaction = await publicClient.getTransaction({ blockTag: 'latest', index: 0 }) + // The ENS name should have resolved to user2's address + const referrerHex = addressToBytes32(accounts.getAddress('user2')) + expect(latestTransaction.input).toContain(referrerHex.slice(2)) // Remove '0x' prefix for comparison + }) + }) }) test('should change profile submit button text from "Skip profile" to "Next" when a header record is added', async ({ @@ -2389,3 +2460,73 @@ test('should allow the user to register with both avatar and header manually set await expect(recordsPage.getRecordValue('text', 'header')).toHaveText(header2) }) }) + +test.describe('Referrer Error Notifications', () => { + test('should show error toast when referrer ENS name does not resolve', async ({ + page, + login, + time, + }) => { + await time.sync(500) + + const name = `registration-invalid-referrer-${Date.now()}.eth` + const invalidReferrer = 'nonexistent-name-that-does-not-exist.eth' + + await page.goto(`/${name}/register?referrer=${invalidReferrer}`) + await login.connect() + + // Wait for the error toast to appear + await expect(page.getByTestId('toast-desktop')).toBeVisible({ timeout: 10000 }) + await expect(page.getByTestId('toast-desktop')).toContainText('Referrer Error') + await expect(page.getByTestId('toast-desktop')).toContainText('did not resolve') + + // Close the toast + await page.getByTestId('toast-close-icon').click() + await expect(page.getByTestId('toast-desktop')).not.toBeVisible() + }) + + test('should show error toast when referrer is invalid format', async ({ page, login, time }) => { + await time.sync(500) + + const name = `registration-invalid-format-referrer-${Date.now()}.eth` + const invalidReferrer = 'not-a-valid-address-or-name' + + await page.goto(`/${name}/register?referrer=${invalidReferrer}`) + await login.connect() + + // Wait for the error toast to appear + await expect(page.getByTestId('toast-desktop')).toBeVisible({ timeout: 10000 }) + await expect(page.getByTestId('toast-desktop')).toContainText('Referrer Error') + + // Close the toast + await page.getByTestId('toast-close-icon').click() + await expect(page.getByTestId('toast-desktop')).not.toBeVisible() + }) + + test('should only show referrer error toast once', async ({ page, login, time }) => { + await time.sync(500) + + const name = `registration-toast-once-${Date.now()}.eth` + const invalidReferrer = 'nonexistent-referrer.eth' + + await page.goto(`/${name}/register?referrer=${invalidReferrer}`) + await login.connect() + + // Wait for the error toast to appear + await expect(page.getByTestId('toast-desktop')).toBeVisible({ timeout: 10000 }) + + // Close the toast + await page.getByTestId('toast-close-icon').click() + await expect(page.getByTestId('toast-desktop')).not.toBeVisible() + + // Navigate away and back - toast should not reappear + await page.goto('/') + await page.goto(`/${name}/register?referrer=${invalidReferrer}`) + + // Wait a bit to ensure toast would have appeared if it was going to + await page.waitForTimeout(2000) + + // Toast should not be visible again for same referrer + await expect(page.getByTestId('toast-desktop')).not.toBeVisible() + }) +}) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index e46343c2c..5fafd7d13 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -193,6 +193,9 @@ "networkLatency": { "title": "Slow data syncing", "message": "The ENS app is experiencing slow downs due to network latency issues." + }, + "referrer": { + "title": "Referrer Error" } }, "transaction": { diff --git a/src/components/@molecules/DesyncedMessage/DesyncedMessage.test.tsx b/src/components/@molecules/DesyncedMessage/DesyncedMessage.test.tsx new file mode 100644 index 000000000..d9e1a1376 --- /dev/null +++ b/src/components/@molecules/DesyncedMessage/DesyncedMessage.test.tsx @@ -0,0 +1,170 @@ +import { mockFunction, render, screen, userEvent } from '@app/test-utils' + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hex } from 'viem' +import { useAccount } from 'wagmi' + +import { useReferrer } from '@app/hooks/useReferrer' +import { useResolvedReferrer } from '@app/hooks/useResolvedReferrer' +import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { ONE_DAY } from '@app/utils/time' + +import { DesyncedMessage } from './DesyncedMessage' + +vi.mock('wagmi') +vi.mock('@app/hooks/useReferrer') +vi.mock('@app/hooks/useResolvedReferrer') +vi.mock('@app/transaction-flow/TransactionFlowProvider') + +const mockUseAccount = mockFunction(useAccount) +const mockUseReferrer = mockFunction(useReferrer) +const mockUseResolvedReferrer = mockFunction(useResolvedReferrer) +const mockUseTransactionFlow = mockFunction(useTransactionFlow) + +const mockCreateTransactionFlow = vi.fn() +const mockShowExtendNamesInput = vi.fn() +const mockUsePreparedDataInput = () => mockShowExtendNamesInput + +describe('DesyncedMessage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseAccount.mockReturnValue({ isConnected: true }) + mockUseReferrer.mockReturnValue(undefined) + mockUseResolvedReferrer.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: null, + }) + mockUseTransactionFlow.mockReturnValue({ + createTransactionFlow: mockCreateTransactionFlow, + usePreparedDataInput: mockUsePreparedDataInput, + }) + }) + + it('should disable action button when referrer is resolving', () => { + mockUseReferrer.mockReturnValue('vitalik.eth') + mockUseResolvedReferrer.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: null, + }) + + render( + , + ) + + const actionButton = screen.getByRole('button') + expect(actionButton).toBeDisabled() + }) + + it('should enable action button when referrer resolution completes', () => { + const mockReferrerHex = + '0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045' as Hex + mockUseReferrer.mockReturnValue('vitalik.eth') + mockUseResolvedReferrer.mockReturnValue({ + data: mockReferrerHex, + isLoading: false, + isError: false, + error: null, + }) + + render( + , + ) + + const actionButton = screen.getByRole('button') + expect(actionButton).not.toBeDisabled() + }) + + it('should pass resolved referrer hex to transaction when clicking action button', async () => { + const mockReferrerHex = + '0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045' as Hex + mockUseReferrer.mockReturnValue('vitalik.eth') + mockUseResolvedReferrer.mockReturnValue({ + data: mockReferrerHex, + isLoading: false, + isError: false, + error: null, + }) + + const futureDate = new Date(Date.now() + ONE_DAY * 1000 + 10000) + + render( + , + ) + + const actionButton = screen.getByRole('button') + await userEvent.click(actionButton) + + expect(mockCreateTransactionFlow).toHaveBeenCalledWith( + 'repair-desynced-name-test.eth', + expect.objectContaining({ + transactions: [ + expect.objectContaining({ + name: 'repairDesyncedName', + data: expect.objectContaining({ + name: 'test.eth', + referrer: mockReferrerHex, + hasWrapped: true, + }), + }), + ], + }), + ) + }) + + it('should pass undefined referrer when no referrer is resolved', async () => { + mockUseReferrer.mockReturnValue(undefined) + mockUseResolvedReferrer.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: null, + }) + + const futureDate = new Date(Date.now() + ONE_DAY * 1000 + 10000) + + render( + , + ) + + const actionButton = screen.getByRole('button') + await userEvent.click(actionButton) + + expect(mockCreateTransactionFlow).toHaveBeenCalledWith( + 'repair-desynced-name-test.eth', + expect.objectContaining({ + transactions: [ + expect.objectContaining({ + name: 'repairDesyncedName', + data: expect.objectContaining({ + name: 'test.eth', + referrer: undefined, + hasWrapped: true, + }), + }), + ], + }), + ) + }) + + it('should call useResolvedReferrer with the referrer param', () => { + mockUseReferrer.mockReturnValue('nick.eth') + + render( + , + ) + + expect(mockUseResolvedReferrer).toHaveBeenCalledWith({ referrer: 'nick.eth' }) + }) +}) diff --git a/src/components/@molecules/DesyncedMessage/DesyncedMessage.tsx b/src/components/@molecules/DesyncedMessage/DesyncedMessage.tsx index f12c7caa2..34060cbf4 100644 --- a/src/components/@molecules/DesyncedMessage/DesyncedMessage.tsx +++ b/src/components/@molecules/DesyncedMessage/DesyncedMessage.tsx @@ -6,9 +6,9 @@ import { ButtonProps } from '@ensdomains/thorin' import { BannerMessageWithAction } from '@app/components/@atoms/BannerMessageWithAction/BannerMessageWithAction' import { useReferrer } from '@app/hooks/useReferrer' +import { useResolvedReferrer } from '@app/hooks/useResolvedReferrer' import { createTransactionItem } from '@app/transaction-flow/transaction' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' -import { getReferrerHex } from '@app/utils/referrer' import { ONE_DAY } from '@app/utils/time' const createKey = (name: string) => `repair-desynced-name-${name}` @@ -34,7 +34,9 @@ export const DesyncedMessage = ({ const { createTransactionFlow, usePreparedDataInput } = useTransactionFlow() const showExtendNamesInput = usePreparedDataInput('ExtendNames') const referrer = useReferrer() - const referrerHex = getReferrerHex(referrer) + const { data: resolvedReferrer, isLoading: isReferrerResolving } = useResolvedReferrer({ + referrer, + }) return ( { if (!name) return const minSeconds = calculateMinSeconds(expiryDate) @@ -66,7 +70,7 @@ export const DesyncedMessage = ({ transactions: [ createTransactionItem('repairDesyncedName', { name, - referrer: referrerHex, + referrer: resolvedReferrer, hasWrapped: true, }), ], diff --git a/src/components/ReferrerNotifications.test.tsx b/src/components/ReferrerNotifications.test.tsx new file mode 100644 index 000000000..988053b27 --- /dev/null +++ b/src/components/ReferrerNotifications.test.tsx @@ -0,0 +1,92 @@ +import { mockFunction, render, screen } from '@app/test-utils' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useReferrer } from '@app/hooks/useReferrer' +import { useResolvedReferrer } from '@app/hooks/useResolvedReferrer' + +import { ReferrerNotifications } from './ReferrerNotifications' + +vi.mock('@app/hooks/useReferrer') +vi.mock('@app/hooks/useResolvedReferrer') +vi.mock('@app/utils/BreakpointProvider', () => ({ + useBreakpoint: () => ({ sm: true }), +})) + +const mockUseReferrer = mockFunction(useReferrer) +const mockUseResolvedReferrer = mockFunction(useResolvedReferrer) + +describe('ReferrerNotifications', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should show toast when referrer resolution errors', () => { + mockUseReferrer.mockReturnValue('invalid.eth') + mockUseResolvedReferrer.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error("ENS name 'invalid.eth' did not resolve to an address"), + }) + + render() + + expect(screen.getByTestId('toast-desktop')).toBeInTheDocument() + expect(screen.getByText("ENS name 'invalid.eth' did not resolve to an address")).toBeInTheDocument() + }) + + it('should not show toast when there is no error', () => { + mockUseReferrer.mockReturnValue('valid.eth') + mockUseResolvedReferrer.mockReturnValue({ + data: '0x1234567890123456789012345678901234567890', + isLoading: false, + isError: false, + error: null, + }) + + render() + + expect(screen.queryByTestId('toast-desktop')).not.toBeInTheDocument() + }) + + it('should not show toast when there is no referrer', () => { + mockUseReferrer.mockReturnValue(undefined) + mockUseResolvedReferrer.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: null, + }) + + render() + + expect(screen.queryByTestId('toast-desktop')).not.toBeInTheDocument() + }) + + it('should not show toast again after closing for same referrer', () => { + mockUseReferrer.mockReturnValue('invalid.eth') + mockUseResolvedReferrer.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error("ENS name 'invalid.eth' did not resolve to an address"), + }) + + const { rerender } = render() + + // Toast should be visible initially + expect(screen.getByTestId('toast-desktop')).toBeInTheDocument() + + // Simulate closing the toast by clicking the close button + const toast = screen.getByTestId('toast-desktop') + const closeButton = toast.querySelector('button') + closeButton?.click() + + // Rerender the component - the hasShownErrorRef should prevent showing again + rerender() + + // Toast component still renders but open state should be false + // The exact behavior depends on Thorin's Toast implementation + }) +}) diff --git a/src/components/ReferrerNotifications.tsx b/src/components/ReferrerNotifications.tsx new file mode 100644 index 000000000..205b271f7 --- /dev/null +++ b/src/components/ReferrerNotifications.tsx @@ -0,0 +1,39 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { Toast } from '@ensdomains/thorin' + +import { useReferrer } from '@app/hooks/useReferrer' +import { useResolvedReferrer } from '@app/hooks/useResolvedReferrer' +import { useBreakpoint } from '@app/utils/BreakpointProvider' + +export const ReferrerNotifications = () => { + const { t } = useTranslation() + const breakpoints = useBreakpoint() + const referrer = useReferrer() + const { isError, error } = useResolvedReferrer({ referrer }) + + const [open, setOpen] = useState(false) + const hasShownErrorRef = useRef(null) + + useEffect(() => { + if (isError && error && referrer && hasShownErrorRef.current !== referrer) { + hasShownErrorRef.current = referrer + setOpen(true) + } else if (!referrer) { + hasShownErrorRef.current = null + } + }, [isError, error, referrer]) + + if (!error) return null + + return ( + setOpen(false)} + variant={breakpoints.sm ? 'desktop' : 'touch'} + title={t('errors.referrer.title')} + description={error.message} + /> + ) +} diff --git a/src/components/pages/profile/[name]/registration/Registration.tsx b/src/components/pages/profile/[name]/registration/Registration.tsx index 00e0d16be..bbcb28e6d 100644 --- a/src/components/pages/profile/[name]/registration/Registration.tsx +++ b/src/components/pages/profile/[name]/registration/Registration.tsx @@ -15,12 +15,12 @@ import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import { useNameDetails } from '@app/hooks/useNameDetails' import { useReferrer } from '@app/hooks/useReferrer' import useRegistrationReducer from '@app/hooks/useRegistrationReducer' +import { useResolvedReferrer } from '@app/hooks/useResolvedReferrer' import { useResolverExists } from '@app/hooks/useResolverExists' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { Content } from '@app/layouts/Content' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' import { sendEvent } from '@app/utils/analytics/events' -import { getReferrerHex } from '@app/utils/referrer' import { isLabelTooLong, secondsToYears } from '@app/utils/utils' import Complete from './steps/Complete' @@ -113,10 +113,12 @@ const Registration = ({ nameDetails, isLoading }: Props) => { const { address } = useAccount() const primary = usePrimaryName({ address }) const referrer = useReferrer() - const referrerHex = getReferrerHex(referrer) + const { data: resolvedReferrer, isLoading: isResolvingReferrer } = useResolvedReferrer({ + referrer, + }) const selected = useMemo( - () => ({ name: nameDetails.normalisedName, address: address!, chainId, referrer: referrerHex }), - [address, chainId, nameDetails.normalisedName, referrerHex], + () => ({ name: nameDetails.normalisedName, address: address!, chainId }), + [address, chainId, nameDetails.normalisedName], ) const { normalisedName, beautifiedName = '' } = nameDetails const defaultResolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) @@ -169,16 +171,18 @@ const Registration = ({ nameDetails, isLoading }: Props) => { initiateMoonpayRegistrationMutation.mutate(secondsToYears(seconds)) return } + if (resolvedReferrer) { + dispatch({ + name: 'setReferrer', + selected, + payload: resolvedReferrer, + }) + } dispatch({ name: 'setPricingData', payload: { seconds, reverseRecord, durationType }, selected, }) - dispatch({ - name: 'setReferrer', - payload: referrerHex, - selected, - }) if (!item.queue.includes('profile')) { // if profile is not in queue, set the default profile data dispatch({ @@ -347,6 +351,7 @@ const Registration = ({ nameDetails, isLoading }: Props) => { registrationData={item} moonpayTransactionStatus={moonpayTransactionStatus} initiateMoonpayRegistrationMutation={initiateMoonpayRegistrationMutation} + isLoading={isResolvingReferrer} /> )) .with([false, 'profile'], () => ( diff --git a/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.test.tsx b/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.test.tsx index 2145daed7..e379bb574 100644 --- a/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.test.tsx +++ b/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.test.tsx @@ -53,4 +53,19 @@ describe('ActionButton', () => { ) expect(screen.getByText('action.next')).toBeInTheDocument() }) + + it('should show loading button when isLoading is true', () => { + render( + , + ) + const button = screen.getByTestId('next-button') + expect(button).toBeDisabled() + expect(screen.getByText('loading')).toBeInTheDocument() + }) }) diff --git a/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.tsx b/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.tsx index e09d34370..2b7b5d451 100644 --- a/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.tsx +++ b/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.tsx @@ -367,6 +367,7 @@ export type ActionButtonProps = { estimatedTotal?: bigint ethPrice?: bigint durationType: 'date' | 'years' + isLoading?: boolean } export const ActionButton = (props: ActionButtonProps) => { @@ -384,6 +385,11 @@ export const ActionButton = (props: ActionButtonProps) => { {t('steps.info.processing')} )) + .with({ isLoading: true }, () => ( + + )) .with( { paymentMethodChoice: PaymentMethod.moonpay }, ({ @@ -486,6 +492,7 @@ export type PricingProps = { initiateMoonpayRegistrationMutation: ReturnType< typeof useMoonpayRegistration >['initiateMoonpayRegistrationMutation'] + isLoading?: boolean } const minSeconds = 28 * ONE_DAY @@ -501,6 +508,7 @@ const Pricing = ({ resolverExists, moonpayTransactionStatus, initiateMoonpayRegistrationMutation, + isLoading, }: PricingProps) => { const { t } = useTranslation('register') @@ -621,6 +629,7 @@ const Pricing = ({ estimatedTotal, ethPrice, durationType, + isLoading, }} /> diff --git a/src/hooks/useResolvedReferrer.test.tsx b/src/hooks/useResolvedReferrer.test.tsx new file mode 100644 index 000000000..2ec0da26d --- /dev/null +++ b/src/hooks/useResolvedReferrer.test.tsx @@ -0,0 +1,112 @@ +import { renderHook, waitFor } from '@app/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Address } from 'viem' + +import { getAddressRecord } from '@ensdomains/ensjs/public' + +import { isValidEnsName } from '@app/utils/ensValidation' + +import { useResolvedReferrer } from './useResolvedReferrer' + +vi.mock('@ensdomains/ensjs/public', () => ({ + getAddressRecord: vi.fn(), +})) + +vi.mock('@app/utils/ensValidation', () => ({ + isValidEnsName: vi.fn(), +})) + +describe('useResolvedReferrer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return undefined when no referrer provided', () => { + const { result } = renderHook(() => useResolvedReferrer({ referrer: undefined })) + + expect(result.current.data).toBeUndefined() + expect(result.current.isLoading).toBe(false) + }) + + it('should resolve ENS name to hex', async () => { + const mockAddress = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' as Address + vi.mocked(isValidEnsName).mockReturnValue(true) + vi.mocked(getAddressRecord).mockResolvedValueOnce({ + value: mockAddress, + coin: 60, + } as any) + + const { result } = renderHook(() => useResolvedReferrer({ referrer: 'vitalik.eth' })) + + await waitFor(() => { + expect(result.current.data).toBeDefined() + expect(result.current.data?.length).toBe(66) + expect(result.current.isLoading).toBe(false) + }) + + expect(vi.mocked(getAddressRecord)).toHaveBeenCalled() + }) + + it('should handle resolution errors gracefully', async () => { + vi.mocked(isValidEnsName).mockReturnValue(true) + vi.mocked(getAddressRecord).mockRejectedValueOnce(new Error('Resolution failed')) + + const { result } = renderHook(() => useResolvedReferrer({ referrer: 'invalid.eth' })) + + await waitFor(() => { + expect(result.current.data).toBeUndefined() + expect(result.current.isError).toBe(true) + }) + }) + + it('should return error when ENS name has no address', async () => { + vi.mocked(isValidEnsName).mockReturnValue(true) + vi.mocked(getAddressRecord).mockResolvedValueOnce(null) + + const { result } = renderHook(() => useResolvedReferrer({ referrer: 'nonexistent.eth' })) + + await waitFor(() => { + expect(result.current.data).toBeUndefined() + expect(result.current.isError).toBe(true) + expect(result.current.error?.message).toBe( + "ENS name 'nonexistent.eth' did not resolve to an address", + ) + }) + }) + + it('should return undefined when referrer is empty string', () => { + const { result } = renderHook(() => useResolvedReferrer({ referrer: '' })) + + expect(result.current.data).toBeUndefined() + expect(result.current.isLoading).toBe(false) + expect(vi.mocked(getAddressRecord)).not.toHaveBeenCalled() + }) + + it('should resolve hex address without calling getAddressRecord', async () => { + const hexAddress = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' + + const { result } = renderHook(() => useResolvedReferrer({ referrer: hexAddress })) + + await waitFor(() => { + expect(result.current.data?.length).toBe(66) + expect(result.current.isLoading).toBe(false) + }) + + // Should not call getAddressRecord for hex addresses + expect(vi.mocked(getAddressRecord)).not.toHaveBeenCalled() + }) + + it('should return error when referrer is not a valid ENS name or hex', async () => { + vi.mocked(isValidEnsName).mockReturnValue(false) + + const { result } = renderHook(() => useResolvedReferrer({ referrer: 'invalid-referrer' })) + + await waitFor(() => { + expect(result.current.data).toBeUndefined() + expect(result.current.isError).toBe(true) + expect(result.current.error?.message).toBe("The referrer 'invalid-referrer' is not valid") + }) + + expect(vi.mocked(getAddressRecord)).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useResolvedReferrer.ts b/src/hooks/useResolvedReferrer.ts new file mode 100644 index 000000000..c45a8a020 --- /dev/null +++ b/src/hooks/useResolvedReferrer.ts @@ -0,0 +1,82 @@ +import { QueryFunctionContext } from '@tanstack/react-query' +import { Hex, isHex } from 'viem' + +import { getAddressRecord } from '@ensdomains/ensjs/public' +import { EMPTY_BYTES32 } from '@ensdomains/ensjs/utils' + +import { useQueryOptions } from '@app/hooks/useQueryOptions' +import { ConfigWithEns, CreateQueryKey, QueryConfig } from '@app/types' +import { isValidEnsName } from '@app/utils/ensValidation' +import { prepareQueryOptions } from '@app/utils/prepareQueryOptions' +import { useQuery } from '@app/utils/query/useQuery' +import { getReferrerHex } from '@app/utils/referrer' + +type UseResolvedReferrerParameters = { + referrer?: string +} + +type UseResolvedReferrerReturnType = Hex | undefined + +type UseResolvedReferrerConfig = QueryConfig + +type UseResolvedReferrerQueryKey< + TParams extends UseResolvedReferrerParameters = UseResolvedReferrerParameters, +> = CreateQueryKey + +const resolveReferrerQueryFn = + (config: ConfigWithEns) => + async ({ + queryKey: [{ referrer }, chainId], + }: QueryFunctionContext>) => { + if (!referrer) return EMPTY_BYTES32 + + if (isHex(referrer)) return getReferrerHex(referrer) + + if (isValidEnsName(referrer)) { + const client = config.getClient({ chainId }) + const addressRecord = await getAddressRecord(client, { name: referrer }) + if (!addressRecord?.value) + throw new Error(`ENS name '${referrer}' did not resolve to an address`) + return getReferrerHex(addressRecord.value) + } + + throw new Error(`The referrer '${referrer}' is not valid`) + } + +export const useResolvedReferrer = ({ + enabled = true, + gcTime, + staleTime, + scopeKey, + ...params +}: TParams & UseResolvedReferrerConfig): { + data: UseResolvedReferrerReturnType + isLoading: boolean + isError: boolean + error: Error | null +} => { + const initialOptions = useQueryOptions({ + params, + scopeKey, + functionName: 'resolveReferrer', + queryDependencyType: 'standard', + queryFn: resolveReferrerQueryFn, + }) + + const preparedOptions = prepareQueryOptions({ + queryKey: initialOptions.queryKey, + queryFn: initialOptions.queryFn, + enabled: enabled && !!params.referrer, + gcTime, + staleTime, + }) + + const query = useQuery(preparedOptions) + + return { + data: query.isError ? undefined : query.data, + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + } +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 750969922..7ef8df6ea 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -17,6 +17,7 @@ import { } from '@ensdomains/thorin' import { NetworkNotifications } from '@app/components/@molecules/NetworkNotifications/NetworkNotifications' +import { ReferrerNotifications } from '@app/components/ReferrerNotifications' import { TestnetWarning } from '@app/components/TestnetWarning' import { TransactionNotifications } from '@app/components/TransactionNotifications' import { TransactionStoreProvider } from '@app/hooks/transactions/TransactionStoreContext' @@ -178,6 +179,7 @@ const AppWithThorin = ({ Component, pageProps }: Omit + {getLayout()} diff --git a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx index 0b96d26d6..0b14bb4e3 100644 --- a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx +++ b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx @@ -1,6 +1,7 @@ import { mockFunction, render, screen } from '@app/test-utils' import { describe, expect, it, vi } from 'vitest' +import { Hex } from 'viem' import { useAccount, useBalance } from 'wagmi' import { useEstimateGasWithStateOverride } from '@app/hooks/chain/useEstimateGasWithStateOverride' @@ -8,6 +9,7 @@ import { useExpiry } from '@app/hooks/ensjs/public/useExpiry' import { usePrice } from '@app/hooks/ensjs/public/usePrice' import { useEthPrice } from '@app/hooks/useEthPrice' import { useReferrer } from '@app/hooks/useReferrer' +import { useResolvedReferrer } from '@app/hooks/useResolvedReferrer' import { makeMockIntersectionObserver } from '../../../../test/mock/makeMockIntersectionObserver' import ExtendNames from './ExtendNames-flow' @@ -18,6 +20,7 @@ vi.mock('wagmi') vi.mock('@app/hooks/ensjs/public/useExpiry') vi.mock('@app/hooks/useEthPrice') vi.mock('@app/hooks/useReferrer') +vi.mock('@app/hooks/useResolvedReferrer') const mockUseEstimateGasWithStateOverride = mockFunction(useEstimateGasWithStateOverride) const mockUsePrice = mockFunction(usePrice) @@ -26,6 +29,7 @@ const mockUseBalance = mockFunction(useBalance) const mockUseEthPrice = mockFunction(useEthPrice) const mockUseExpiry = mockFunction(useExpiry) const mockUseReferrer = mockFunction(useReferrer) +const mockUseResolvedReferrer = mockFunction(useResolvedReferrer) vi.mock('@ensdomains/thorin', async () => { const originalModule = await vi.importActual('@ensdomains/thorin') @@ -63,6 +67,12 @@ describe('Extendnames', () => { mockUseEthPrice.mockReturnValue({ data: 100n, isLoading: false }) mockUseExpiry.mockReturnValue({ data: { expiry: { date: new Date() } }, isLoading: false }) mockUseReferrer.mockReturnValue(undefined) + mockUseResolvedReferrer.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + error: null, + }) it('should render', async () => { render( { const trailingButton = screen.getByTestId('extend-names-confirm') expect(trailingButton).toHaveAttribute('disabled') }) + + it('should resolve ENS name referrer to hex', async () => { + const mockReferrerHex = '0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045' as Hex + + // Mock useReferrer to return an ENS name + mockUseReferrer.mockReturnValueOnce('vitalik.eth') + + // Mock useResolvedReferrer to return resolved hex + mockUseResolvedReferrer.mockReturnValueOnce({ + data: mockReferrerHex, + isLoading: false, + isError: false, + error: null, + }) + + render( + null, + onDismiss: () => null, + }} + />, + ) + + // Verify that useResolvedReferrer was called with the ENS name + expect(mockUseResolvedReferrer).toHaveBeenCalledWith({ referrer: 'vitalik.eth' }) + }) + + it('should handle failed referrer resolution gracefully', async () => { + mockUseReferrer.mockReturnValueOnce('invalid.eth') + mockUseResolvedReferrer.mockReturnValueOnce({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('Resolution failed'), + }) + + render( + null, + onDismiss: () => null, + }} + />, + ) + + // Component should render without crashing even if resolution fails + expect(screen.getByTestId('extend-names-modal')).toBeInTheDocument() + }) + + it('should show disabled button but display pricing content while referrer is resolving', () => { + mockUseReferrer.mockReturnValueOnce('vitalik.eth') + mockUseResolvedReferrer.mockReturnValueOnce({ + data: undefined, + isLoading: true, + isError: false, + error: null, + }) + + render( + null, + onDismiss: () => null, + }} + />, + ) + + // Pricing content should be visible (Invoice is rendered) + expect(screen.getByText('Invoice')).toBeInTheDocument() + + // Next button should be disabled while referrer is resolving + const trailingButton = screen.getByTestId('extend-names-confirm') + expect(trailingButton).toHaveAttribute('disabled') + }) }) diff --git a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx index 7be335e15..dae497767 100644 --- a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx +++ b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx @@ -20,11 +20,11 @@ import { usePrice } from '@app/hooks/ensjs/public/usePrice' import { useEnsAvatar } from '@app/hooks/useEnsAvatar' import { useEthPrice } from '@app/hooks/useEthPrice' import { useReferrer } from '@app/hooks/useReferrer' +import { useResolvedReferrer } from '@app/hooks/useResolvedReferrer' import { useZorb } from '@app/hooks/useZorb' import { createTransactionItem } from '@app/transaction-flow/transaction' import { TransactionDialogPassthrough } from '@app/transaction-flow/types' import { CURRENCY_FLUCTUATION_BUFFER_PERCENTAGE } from '@app/utils/constants' -import { getReferrerHex } from '@app/utils/referrer' import { ONE_DAY, ONE_YEAR, secondsToYears, yearsToSeconds } from '@app/utils/time' import useUserConfig from '@app/utils/useUserConfig' import { deriveYearlyFee, formatDurationOfDates } from '@app/utils/utils' @@ -188,7 +188,9 @@ const ExtendNames = ({ const [durationType, setDurationType] = useState<'years' | 'date'>('years') const referrer = useReferrer() - const referrerHex = getReferrerHex(referrer) + const { data: resolvedReferrer, isLoading: isReferrerResolving } = useResolvedReferrer({ + referrer, + }) const { data: ethPrice, isLoading: isEthPriceLoading } = useEthPrice() const { address, isConnected: isAccountConnected } = useAccount() @@ -232,7 +234,7 @@ const ExtendNames = ({ duration: seconds, names, startDateTimestamp: expiryDate?.getTime(), - referrer: referrerHex, + referrer: resolvedReferrer, hasWrapped, }, stateOverride: [ @@ -285,7 +287,8 @@ const ExtendNames = ({ const isBaseDataLoading = !isAccountConnected || isBalanceLoading || isExpiryEnabledAndLoading || isEthPriceLoading - const isRegisterLoading = isPriceLoading || (isEstimateGasLoading && !estimateGasLimitError) + const isRegisterLoading = + isPriceLoading || (isEstimateGasLoading && !estimateGasLimitError) || isReferrerResolving const { title, alert, buttonProps } = match(view) .with('no-ownership-warning', () => ({ @@ -312,6 +315,7 @@ const ExtendNames = ({ alert: undefined, buttonProps: { disabled: isRegisterLoading, + loading: isRegisterLoading, onClick: () => { if (!totalRentFee) return const transactions = createTransactionItem('extendNames', { @@ -324,7 +328,7 @@ const ExtendNames = ({ bufferPercentage: CURRENCY_FLUCTUATION_BUFFER_PERCENTAGE, currency: userConfig.currency === 'fiat' ? 'usd' : 'eth', }), - referrer: referrerHex, + referrer: resolvedReferrer, hasWrapped, }) dispatch({ name: 'setTransactions', payload: [transactions] }) diff --git a/src/utils/ensValidation.ts b/src/utils/ensValidation.ts new file mode 100644 index 000000000..1a10d0af4 --- /dev/null +++ b/src/utils/ensValidation.ts @@ -0,0 +1,13 @@ +import { normalize } from 'viem/ens' + +export const tryNormalize = (name: string): string | null => { + try { + return normalize(name) + } catch { + return null + } +} + +export const isValidEnsName = (name: string): boolean => { + return tryNormalize(name) !== null +}