From 64305b7f2eaf9c0b7e75afb1d7e49e6461dfd3c1 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Fri, 16 Jan 2026 11:43:57 +0800 Subject: [PATCH 1/6] feat: update primary name flows to support default reverse registry Add support for the default reverse registry in primary name flows: - Add useDefaultReverseRegistryName hook to query default registry - Add usePrimaryNameFromSources hook to combine L1 and default sources - Add setDefaultPrimaryName and resetDefaultPrimaryName transactions - Update ResetPrimaryName flow to handle both registries - Update SelectPrimaryName to use new primary name source hook - Add translations for new transaction types --- .gitignore | 2 + public/locales/en/common.json | 3 + .../PrimarySection/PrimarySection.tsx | 31 +- .../useDefaultReverseRegistryName.test.ts | 106 ++++ .../public/useDefaultReverseRegistryName.ts | 98 ++++ .../index.ts | 48 +- .../primary/usePrimaryNameFromSources.test.ts | 501 ++++++++++++++++++ .../primary/usePrimaryNameFromSources.ts | 77 +++ .../ResetPrimaryName-flow.tsx | 37 +- .../SelectPrimaryName-flow.tsx | 6 +- src/transaction-flow/transaction/index.ts | 4 + .../transaction/resetDefaultPrimaryName.ts | 56 ++ .../transaction/setDefaultPrimaryName.ts | 63 +++ 13 files changed, 980 insertions(+), 52 deletions(-) create mode 100644 src/hooks/ensjs/public/useDefaultReverseRegistryName.test.ts create mode 100644 src/hooks/ensjs/public/useDefaultReverseRegistryName.ts create mode 100644 src/hooks/primary/usePrimaryNameFromSources.test.ts create mode 100644 src/hooks/primary/usePrimaryNameFromSources.ts create mode 100644 src/transaction-flow/transaction/resetDefaultPrimaryName.ts create mode 100644 src/transaction-flow/transaction/setDefaultPrimaryName.ts diff --git a/.gitignore b/.gitignore index 215e40d3d..4c1ac6f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,5 @@ certificates # Claude .playwright-mcp +.claude +thoughts/ \ No newline at end of file diff --git a/public/locales/en/common.json b/public/locales/en/common.json index e46343c2c..da77bc405 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -210,7 +210,9 @@ "updateResolver": "Update resolver", "updateProfile": "Update profile", "setPrimaryName": "Set primary name", + "setDefaultPrimaryName": "Set default primary name", "resetPrimaryName": "Remove primary name", + "resetDefaultPrimaryName": "Remove default primary name", "updateEthAddress": "Update ETH address", "testSendName": "Send name", "burnFuses": "Burn permissions", @@ -249,6 +251,7 @@ "updateResolver": "Change resolver to", "updateProfile": "Update records on existing resolver", "setPrimaryName": "Set the primary name for your address", + "setDefaultPrimaryName": "Set the default primary name for your address", "updateEthAddress": "Update ETH address to this address", "updateEthAddressOnLatestResolver": "Update ETH address on latest resolver", "testSendName": "Set the controller and registrant of the name", diff --git a/src/components/pages/profile/settings/PrimarySection/PrimarySection.tsx b/src/components/pages/profile/settings/PrimarySection/PrimarySection.tsx index 32beab5fb..2b71fa464 100644 --- a/src/components/pages/profile/settings/PrimarySection/PrimarySection.tsx +++ b/src/components/pages/profile/settings/PrimarySection/PrimarySection.tsx @@ -7,13 +7,12 @@ import { AvatarWithLink } from '@app/components/@molecules/AvatarWithLink/Avatar import { DisabledButtonWithTooltip } from '@app/components/@molecules/DisabledButtonWithTooltip' import { getNetworkFromUrl } from '@app/constants/chains' import { useAccountSafely } from '@app/hooks/account/useAccountSafely' -import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' -import { useReverseRegistryName } from '@app/hooks/ensjs/public/useReverseRegistryName' import { useBasicName } from '@app/hooks/useBasicName' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' import { useHasGraphError } from '@app/utils/SyncProvider/SyncProvider' import { NetworkSpecificPrimaryNamesSection } from './NetworkSpecificPrimaryNamesSection' +import { usePrimaryNameFromSources } from '@app/hooks/primary/usePrimaryNameFromSources' const SkeletonFiller = styled.div( ({ theme }) => css` @@ -142,11 +141,9 @@ export const PrimarySection = () => { const showSelectPrimaryNameInput = usePreparedDataInput('SelectPrimaryName') const showResetPrimaryNameInput = usePreparedDataInput('ResetPrimaryName') - const primary = usePrimaryName({ address }) - const reverseRegistryName = useReverseRegistryName({ address }) + const primary = usePrimaryNameFromSources({ address }) - const isHeritedName = - primary.data?.name && !reverseRegistryName.data && reverseRegistryName.isSuccess + const isHeritedName = primary.data?.source === 'default' const { truncatedName, isLoading: basicLoading } = useBasicName({ name: primary.data?.name, @@ -216,18 +213,16 @@ export const PrimarySection = () => { ) : ( <> - {!isHeritedName && ( - - )} + } trailing={ - } diff --git a/src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName-flow.tsx b/src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName-flow.tsx index 529c45d00..9ca47f6e7 100644 --- a/src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName-flow.tsx +++ b/src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName-flow.tsx @@ -18,7 +18,7 @@ import { SortType, } from '@app/components/@molecules/NameTableHeader/NameTableHeader' import { SpinnerRow } from '@app/components/@molecules/ScrollBoxWithSpinner' -import { useReverseRegistryName } from '@app/hooks/ensjs/public/useReverseRegistryName' +import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import { useNamesForAddress } from '@app/hooks/ensjs/subgraph/useNamesForAddress' import { useGetPrimaryNameTransactionFlowItem } from '@app/hooks/primary/useGetPrimaryNameTransactionFlowItem' import { useResolverStatus } from '@app/hooks/resolver/useResolverStatus' @@ -134,7 +134,7 @@ const SelectPrimaryName = ({ data: { address }, dispatch, onDismiss }: Props) => const [searchQuery, _setSearchQuery] = useState('') const setSearchQuery = useDebouncedCallback(_setSearchQuery, 300, []) - const currentPrimary = useReverseRegistryName({ address }) + const currentPrimary = usePrimaryName({ address }) const { data: namesData, hasNextPage, @@ -153,7 +153,7 @@ const SelectPrimaryName = ({ data: { address }, dispatch, onDismiss }: Props) => // Filter out the primary name's data const filteredNamesPages = namesData?.pages?.map((page: Name[]) => - page.filter((name: Name) => name?.name !== currentPrimary?.data), + page.filter((name: Name) => name?.name !== currentPrimary?.data?.name), ) || [] const selectedName = useWatch({ diff --git a/src/transaction-flow/transaction/index.ts b/src/transaction-flow/transaction/index.ts index 136b556db..b0ce721c4 100644 --- a/src/transaction-flow/transaction/index.ts +++ b/src/transaction-flow/transaction/index.ts @@ -13,9 +13,11 @@ import migrateProfileWithReset from './migrateProfileWithReset' import registerName from './registerName' import removeVerificationRecord from './removeVerificationRecord' import repairDesyncedName from './repairDesyncedName' +import resetDefaultPrimaryName from './resetDefaultPrimaryName' import resetPrimaryName from './resetPrimaryName' import resetProfile from './resetProfile' import resetProfileWithRecords from './resetProfileWithRecords' +import setDefaultPrimaryName from './setDefaultPrimaryName' import setPrimaryName from './setPrimaryName' import syncManager from './syncManager' import testSendName from './testSendName' @@ -45,9 +47,11 @@ export const transactions = { migrateProfileWithReset, registerName, repairDesyncedName, + resetDefaultPrimaryName, resetPrimaryName, resetProfile, resetProfileWithRecords, + setDefaultPrimaryName, setPrimaryName, syncManager, testSendName, diff --git a/src/transaction-flow/transaction/resetDefaultPrimaryName.ts b/src/transaction-flow/transaction/resetDefaultPrimaryName.ts new file mode 100644 index 000000000..c8e3dcf42 --- /dev/null +++ b/src/transaction-flow/transaction/resetDefaultPrimaryName.ts @@ -0,0 +1,56 @@ +import type { TFunction } from 'react-i18next' +import { Address, encodeFunctionData } from 'viem' + +import { getChainContractAddress } from '@ensdomains/ensjs/contracts' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + address: Address +} + +const defaultReverseRegistrarSetNameSnippet = [ + { + inputs: [ + { + name: 'name', + type: 'string', + }, + ], + name: 'setName', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const + +const displayItems = ( + { address }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'address', + value: address, + type: 'address', + }, + { + label: 'action', + value: t(`transaction.description.resetDefaultPrimaryName`), + }, +] + +const transaction = async ({ client }: TransactionFunctionParameters) => { + return { + to: getChainContractAddress({ + client, + contract: 'ensDefaultReverseRegistrar', + }), + data: encodeFunctionData({ + abi: defaultReverseRegistrarSetNameSnippet, + functionName: 'setName', + args: [''], + }), + } +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction-flow/transaction/setDefaultPrimaryName.ts b/src/transaction-flow/transaction/setDefaultPrimaryName.ts new file mode 100644 index 000000000..745db8c5e --- /dev/null +++ b/src/transaction-flow/transaction/setDefaultPrimaryName.ts @@ -0,0 +1,63 @@ +import type { TFunction } from 'react-i18next' +import type { Address } from 'viem' +import { encodeFunctionData } from 'viem' + +import { getChainContractAddress } from '@ensdomains/ensjs/contracts' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + name: string + address: Address +} + +const defaultReverseRegistrarSetNameSnippet = [ + { + inputs: [ + { + name: 'name', + type: 'string', + }, + ], + name: 'setName', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const + +const displayItems = ( + { address, name }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'info', + value: t(`transaction.info.setDefaultPrimaryName`), + }, + { + label: 'address', + value: address, + type: 'address', + }, +] + +const transaction = async ({ client, data }: TransactionFunctionParameters) => { + return { + to: getChainContractAddress({ + client, + contract: 'ensDefaultReverseRegistrar', + }), + data: encodeFunctionData({ + abi: defaultReverseRegistrarSetNameSnippet, + functionName: 'setName', + args: [data.name], + }), + } +} + +export default { displayItems, transaction } satisfies Transaction From afbb5616a37db15d040411cef747a04334fe5fb0 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Mon, 19 Jan 2026 14:14:38 +0800 Subject: [PATCH 2/6] fix: resolve linting errors and update primary name tests Fix variable shadowing in usePrimaryNameFromSources by using query object access instead of destructuring. Update test mocks to use usePrimaryNameFromSources instead of useReverseRegistryName. Add e2e test fixtures for primary name state management. --- .../stateless/primaryNameUtility.spec.ts | 259 ++++++++++++++ e2e/specs/stateless/setPrimaryDefault.spec.ts | 331 ++++++++++++++++++ playwright/fixtures/primaryName.ts | 176 ++++++++++ playwright/index.ts | 21 +- .../PrimarySection/PrimarySection.tsx | 2 +- .../index.ts | 24 +- ...eGetPrimaryNameTransactionFlowItem.test.ts | 40 ++- .../primary/usePrimaryNameFromSources.ts | 45 ++- .../SelectPrimaryName.test.tsx | 15 +- 9 files changed, 871 insertions(+), 42 deletions(-) create mode 100644 e2e/specs/stateless/primaryNameUtility.spec.ts create mode 100644 e2e/specs/stateless/setPrimaryDefault.spec.ts create mode 100644 playwright/fixtures/primaryName.ts diff --git a/e2e/specs/stateless/primaryNameUtility.spec.ts b/e2e/specs/stateless/primaryNameUtility.spec.ts new file mode 100644 index 000000000..05b37719b --- /dev/null +++ b/e2e/specs/stateless/primaryNameUtility.spec.ts @@ -0,0 +1,259 @@ +import { expect } from '@playwright/test' + +import { test } from '../../../playwright' + +test.describe('Primary Name Utility Functions', () => { + test.describe('setPrimaryNameState', () => { + test('should set L1 primary name only', async ({ makeName, primaryName }) => { + const name = await makeName({ + label: 'l1-only-test', + type: 'legacy', + owner: 'user', + }) + + // Clear any existing state + await primaryName.clearAll('user') + + // Set only L1 + await primaryName.setState({ + user: 'user', + state: { l1: name }, + }) + + const state = await primaryName.getState('user') + expect(state.l1).toBe(name) + expect(state.default).toBeFalsy() + }) + + test('should set default primary name only', async ({ makeName, primaryName }) => { + const name = await makeName({ + label: 'default-only-test', + type: 'legacy', + owner: 'user', + }) + + // Clear any existing state + await primaryName.clearAll('user') + + // Set only default + await primaryName.setState({ + user: 'user', + state: { default: name }, + }) + + const state = await primaryName.getState('user') + expect(state.l1).toBeFalsy() + expect(state.default).toBe(name) + }) + + test('should set both L1 and default primary names', async ({ makeName, primaryName }) => { + const name = await makeName({ + label: 'both-test', + type: 'legacy', + owner: 'user', + }) + + // Clear any existing state + await primaryName.clearAll('user') + + // Set both registries + await primaryName.setState({ + user: 'user', + state: { l1: name, default: name }, + }) + + const state = await primaryName.getState('user') + expect(state.l1).toBe(name) + expect(state.default).toBe(name) + }) + + test('should set different names in L1 and default registries', async ({ + makeName, + primaryName, + }) => { + const [name1, name2] = await makeName([ + { label: 'l1-name', type: 'legacy', owner: 'user' }, + { label: 'default-name', type: 'legacy', owner: 'user' }, + ]) + + // Clear any existing state + await primaryName.clearAll('user') + + // Set different names + await primaryName.setState({ + user: 'user', + state: { l1: name1, default: name2 }, + }) + + const state = await primaryName.getState('user') + expect(state.l1).toBe(name1) + expect(state.default).toBe(name2) + }) + + test('should clear L1 primary name', async ({ makeName, primaryName }) => { + const name = await makeName({ + label: 'clear-l1-test', + type: 'legacy', + owner: 'user', + }) + + // Set L1 first + await primaryName.setState({ + user: 'user', + state: { l1: name, default: '' }, + }) + + // Verify it's set + let state = await primaryName.getState('user') + expect(state.l1).toBe(name) + + // Clear L1 + await primaryName.setState({ + user: 'user', + state: { l1: '' }, + }) + + state = await primaryName.getState('user') + expect(state.l1).toBeFalsy() + }) + + test('should clear default primary name', async ({ makeName, primaryName }) => { + const name = await makeName({ + label: 'clear-default-test', + type: 'legacy', + owner: 'user', + }) + + // Set default first + await primaryName.setState({ + user: 'user', + state: { l1: '', default: name }, + }) + + // Verify it's set + let state = await primaryName.getState('user') + expect(state.default).toBe(name) + + // Clear default + await primaryName.setState({ + user: 'user', + state: { default: '' }, + }) + + state = await primaryName.getState('user') + expect(state.default).toBeFalsy() + }) + + test('should not modify L1 when only default is specified', async ({ + makeName, + primaryName, + }) => { + const [l1Name, defaultName] = await makeName([ + { label: 'preserve-l1', type: 'legacy', owner: 'user' }, + { label: 'new-default', type: 'legacy', owner: 'user' }, + ]) + + // Set initial L1 state + await primaryName.setState({ + user: 'user', + state: { l1: l1Name, default: '' }, + }) + + // Only modify default (l1 should remain) + await primaryName.setState({ + user: 'user', + state: { default: defaultName }, + }) + + const state = await primaryName.getState('user') + expect(state.l1).toBe(l1Name) + expect(state.default).toBe(defaultName) + }) + + test('should not modify default when only L1 is specified', async ({ + makeName, + primaryName, + }) => { + const [l1Name, defaultName] = await makeName([ + { label: 'new-l1', type: 'legacy', owner: 'user' }, + { label: 'preserve-default', type: 'legacy', owner: 'user' }, + ]) + + // Set initial default state + await primaryName.setState({ + user: 'user', + state: { l1: '', default: defaultName }, + }) + + // Only modify L1 (default should remain) + await primaryName.setState({ + user: 'user', + state: { l1: l1Name }, + }) + + const state = await primaryName.getState('user') + expect(state.l1).toBe(l1Name) + expect(state.default).toBe(defaultName) + }) + }) + + test.describe('clearAllPrimaryNames', () => { + test('should clear both L1 and default primary names', async ({ makeName, primaryName }) => { + const name = await makeName({ + label: 'clear-all-test', + type: 'legacy', + owner: 'user', + }) + + // Set both + await primaryName.setState({ + user: 'user', + state: { l1: name, default: name }, + }) + + // Verify both are set + let state = await primaryName.getState('user') + expect(state.l1).toBe(name) + expect(state.default).toBe(name) + + // Clear all + await primaryName.clearAll('user') + + state = await primaryName.getState('user') + expect(state.l1).toBeFalsy() + expect(state.default).toBeFalsy() + }) + }) + + test.describe('getPrimaryNameState', () => { + test('should return falsy for both when no primary names are set', async ({ primaryName }) => { + await primaryName.clearAll('user') + + const state = await primaryName.getState('user') + expect(state.l1).toBeFalsy() + expect(state.default).toBeFalsy() + }) + + test('should correctly distinguish L1 source from default source', async ({ + makeName, + primaryName, + }) => { + const [l1Name, defaultName] = await makeName([ + { label: 'source-l1', type: 'legacy', owner: 'user' }, + { label: 'source-default', type: 'legacy', owner: 'user' }, + ]) + + await primaryName.setState({ + user: 'user', + state: { l1: l1Name, default: defaultName }, + }) + + const state = await primaryName.getState('user') + + // L1 and default should be different names + expect(state.l1).toBe(l1Name) + expect(state.default).toBe(defaultName) + expect(state.l1).not.toBe(state.default) + }) + }) +}) diff --git a/e2e/specs/stateless/setPrimaryDefault.spec.ts b/e2e/specs/stateless/setPrimaryDefault.spec.ts new file mode 100644 index 000000000..1b8620f57 --- /dev/null +++ b/e2e/specs/stateless/setPrimaryDefault.spec.ts @@ -0,0 +1,331 @@ +import { expect } from '@playwright/test' + +import { test } from '../../../playwright' +import { createAccounts } from '../../../playwright/fixtures/accounts' + +test.describe('Primary Name with Default Registry', () => { + test.describe('Setting Primary Name', () => { + test('should set primary to default registry when user has no L1 primary', async ({ + page, + login, + makeName, + makePageObject, + primaryName, + }) => { + test.slow() + + const name = await makeName({ + label: 'set-default-primary', + type: 'legacy', + owner: 'user', + addr: 'user', + }) + + // Clear all primary names - user has NO L1 primary + await primaryName.clearAll('user') + + const profilePage = makePageObject('ProfilePage') + const transactionModal = makePageObject('TransactionModal') + + const accounts = createAccounts() + + await profilePage.goto(name) + await login.connect() + + // Wait for profile to load with ETH address visible + await expect(page.getByTestId('address-profile-button-eth')).toContainText( + accounts.getAddress('user', 5), + ) + + await page.getByText('Set as primary name').click() + + // Should show "Set the default primary name" (not regular primary) + await expect(page.getByTestId('display-item-info-normal')).toContainText( + 'Set the default primary name for your address', + ) + + await transactionModal.autoComplete() + + // Verify the primary name is set + await expect(page.getByTestId('profile-title')).toHaveText(name) + + // Verify the state via utility + const state = await primaryName.getState('user') + expect(state.default).toBe(name) + expect(state.l1).toBeFalsy() + }) + + test('should set primary to L1 registry when user already has L1 primary', async ({ + page, + login, + makeName, + makePageObject, + primaryName, + }) => { + test.slow() + + const [existingPrimary, newPrimary] = await makeName([ + { + label: 'existing-l1-primary', + type: 'legacy', + owner: 'user', + addr: 'user', + }, + { + label: 'new-l1-primary', + type: 'legacy', + owner: 'user', + addr: 'user', + }, + ]) + + // Set existing L1 primary (user HAS L1 primary) + await primaryName.setState({ + user: 'user', + state: { l1: existingPrimary, default: '' }, + }) + + const profilePage = makePageObject('ProfilePage') + const transactionModal = makePageObject('TransactionModal') + + const accounts = createAccounts() + + await profilePage.goto(newPrimary) + await login.connect() + + // Wait for profile to load with ETH address visible + await expect(page.getByTestId('address-profile-button-eth')).toContainText( + accounts.getAddress('user', 5), + ) + + await page.getByText('Set as primary name').click() + + // Should show regular "Set the primary name" (not default) + await expect(page.getByTestId('display-item-info-normal')).toContainText( + 'Set the primary name for your address', + ) + + await transactionModal.autoComplete() + + // Verify the primary name is updated + await expect(page.getByTestId('profile-title')).toHaveText(newPrimary) + + // Verify the state - L1 should be updated + const state = await primaryName.getState('user') + expect(state.l1).toBe(newPrimary) + }) + }) + + test.describe('Resetting Primary Name', () => { + test('should reset only L1 when no default exists', async ({ + page, + login, + makeName, + makePageObject, + primaryName, + }) => { + test.slow() + + const name = await makeName({ + label: 'reset-l1-only', + type: 'legacy', + owner: 'user', + addr: 'user', + }) + + // Set only L1 primary + await primaryName.setState({ + user: 'user', + state: { l1: name, default: '' }, + }) + + const settingsPage = makePageObject('SettingsPage') + const transactionModal = makePageObject('TransactionModal') + + await settingsPage.goto() + await login.connect() + + await expect(settingsPage.getPrimaryNameLabel()).toHaveText(name, { timeout: 15000 }) + + await page.getByTestId('reset-primary-name-button').click() + await page.getByTestId('primary-next').click() + + // Should NOT show intro with steps (single transaction) + await expect(page.getByTestId('display-item-Step 1-normal')).toHaveCount(0) + + await transactionModal.autoComplete() + + // Verify both are cleared + const state = await primaryName.getState('user') + expect(state.l1).toBeFalsy() + expect(state.default).toBeFalsy() + }) + + test('should reset only default when no L1 exists', async ({ + page, + login, + makeName, + makePageObject, + primaryName, + }) => { + test.slow() + + const name = await makeName({ + label: 'reset-default-only', + type: 'legacy', + owner: 'user', + addr: 'user', + }) + + // Set only default primary (no L1) + await primaryName.setState({ + user: 'user', + state: { l1: '', default: name }, + }) + + const settingsPage = makePageObject('SettingsPage') + const transactionModal = makePageObject('TransactionModal') + + await settingsPage.goto() + await login.connect() + + await expect(settingsPage.getPrimaryNameLabel()).toHaveText(name, { timeout: 15000 }) + + await page.getByTestId('reset-primary-name-button').click() + await page.getByTestId('primary-next').click() + + // Should NOT show intro with steps (single transaction) + await expect(page.getByTestId('display-item-Step 1-normal')).toHaveCount(0) + + await transactionModal.autoComplete() + + // Verify both are cleared + const state = await primaryName.getState('user') + expect(state.l1).toBeFalsy() + expect(state.default).toBeFalsy() + }) + + test('should show intro and reset both when L1 and default exist', async ({ + page, + login, + makeName, + makePageObject, + primaryName, + }) => { + test.slow() + + const name = await makeName({ + label: 'reset-both', + type: 'legacy', + owner: 'user', + addr: 'user', + }) + + // Set BOTH L1 and default primary + await primaryName.setState({ + user: 'user', + state: { l1: name, default: name }, + }) + + const settingsPage = makePageObject('SettingsPage') + const transactionModal = makePageObject('TransactionModal') + + await settingsPage.goto() + await login.connect() + + await expect(settingsPage.getPrimaryNameLabel()).toHaveText(name, { timeout: 15000 }) + + await page.getByTestId('reset-primary-name-button').click() + await page.getByTestId('primary-next').click() + + // SHOULD show intro with steps (multiple transactions) + await expect(page.getByTestId('display-item-Step 1-normal')).toBeVisible() + await expect(page.getByTestId('display-item-Step 2-normal')).toBeVisible() + + await transactionModal.autoComplete() + + // Verify both are cleared + const state = await primaryName.getState('user') + expect(state.l1).toBeFalsy() + expect(state.default).toBeFalsy() + }) + }) + + test.describe('UI Behavior', () => { + test('should show remove button when primary comes from default registry', async ({ + page, + login, + makeName, + makePageObject, + primaryName, + }) => { + test.slow() + + const name = await makeName({ + label: 'default-source-ui', + type: 'legacy', + owner: 'user', + addr: 'user', + }) + + // Set only default (inherited/fallback) + await primaryName.setState({ + user: 'user', + state: { l1: '', default: name }, + }) + + const settingsPage = makePageObject('SettingsPage') + + await settingsPage.goto() + await login.connect() + + await expect(settingsPage.getPrimaryNameLabel()).toHaveText(name, { timeout: 15000 }) + + // Remove button SHOULD be visible (was previously hidden for inherited names) + await expect(page.getByTestId('reset-primary-name-button')).toBeVisible() + }) + + test('should filter current primary from selection list', async ({ + page, + login, + makeName, + makePageObject, + primaryName, + }) => { + test.slow() + + const [currentPrimary, otherName] = await makeName([ + { label: 'current-primary', type: 'legacy', owner: 'user', addr: 'user' }, + { label: 'other-name', type: 'legacy', owner: 'user', addr: 'user' }, + ]) + + await primaryName.setState({ + user: 'user', + state: { l1: currentPrimary, default: '' }, + }) + + const settingsPage = makePageObject('SettingsPage') + const selectPrimaryNameModal = makePageObject('SelectPrimaryNameModal') + + await settingsPage.goto() + await login.connect() + + await settingsPage.changePrimaryNameButton.click() + await selectPrimaryNameModal.waitForPageLoad() + + // Current primary should NOT be in the list + const currentLabel = currentPrimary.slice(0, -4) + await selectPrimaryNameModal.searchInput.fill(currentLabel) + await selectPrimaryNameModal.searchInput.press('Enter') + await selectPrimaryNameModal.waitForPageLoad() + await expect(page.getByText('No names found')).toBeVisible({ timeout: 30000 }) + + // Other name SHOULD be in the list + const otherLabel = otherName.slice(0, -4) + await selectPrimaryNameModal.searchInput.fill(otherLabel) + await selectPrimaryNameModal.searchInput.press('Enter') + await selectPrimaryNameModal.waitForPageLoad() + await expect(await selectPrimaryNameModal.getPrimaryNameItem(otherLabel)).toBeVisible() + }) + }) +}) diff --git a/playwright/fixtures/primaryName.ts b/playwright/fixtures/primaryName.ts new file mode 100644 index 000000000..41703e21b --- /dev/null +++ b/playwright/fixtures/primaryName.ts @@ -0,0 +1,176 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { Address, encodeFunctionData, namehash, parseAbi } from 'viem' + +import { registryResolverSnippet } from '@ensdomains/ensjs/contracts' +import { setPrimaryName, setResolver } from '@ensdomains/ensjs/wallet' + +import { Accounts, User } from './accounts' +import { + deploymentAddresses, + publicClient, + walletClient, + waitForTransaction, +} from './contracts/utils/addTestContracts' + +// ABI for the DefaultReverseRegistrar contract +const defaultReverseRegistrarAbi = parseAbi([ + 'function setName(string name) external returns (bytes32)', + 'function nameForAddr(address addr) external view returns (string)', +]) + +// ABI for resolver name() function +const resolverNameSnippet = parseAbi([ + 'function name(bytes32 node) external view returns (string)', +]) + +// Helper to get the reverse node for an address (e.g., "0x1234...abcd.addr.reverse") +const getReverseNode = (address: Address): string => + `${address.toLowerCase().slice(2)}.addr.reverse` + +type PrimaryNameState = { + l1?: string // Name to set in L1 registry ('' to clear, undefined to skip) + default?: string // Name to set in default registry ('' to clear, undefined to skip) +} + +type SetPrimaryNameStateParams = { + user: User + state: PrimaryNameState +} + +type GetPrimaryNameStateResult = { + l1: string | null + default: string | null +} + +/** + * Sets the primary name state for a user in both L1 and default registries. + * + * @example + * // Set only L1 primary name + * await setPrimaryNameState(accounts, { user: 'user', state: { l1: 'myname.eth' } }) + * + * // Set only default primary name + * await setPrimaryNameState(accounts, { user: 'user', state: { default: 'myname.eth' } }) + * + * // Set both registries + * await setPrimaryNameState(accounts, { user: 'user', state: { l1: 'myname.eth', default: 'myname.eth' } }) + * + * // Clear L1, set default + * await setPrimaryNameState(accounts, { user: 'user', state: { l1: '', default: 'myname.eth' } }) + * + * // Clear both registries + * await setPrimaryNameState(accounts, { user: 'user', state: { l1: '', default: '' } }) + */ +export async function setPrimaryNameState( + accounts: Accounts, + { user, state }: SetPrimaryNameStateParams, +): Promise { + const address = accounts.getAddress(user) as Address + const account = accounts.getAccountForUser(user) + + // Set L1 primary name if specified + if (state.l1 !== undefined) { + if (state.l1 === '') { + // To clear L1 primary name, set the resolver for the reverse node to zero address + const functionData = setResolver.makeFunctionData(walletClient, { + name: getReverseNode(address), + contract: 'registry', + resolverAddress: '0x0000000000000000000000000000000000000000', + }) + const tx = await walletClient.sendTransaction({ + account, + ...functionData, + }) + await waitForTransaction(tx) + } else { + // Set the primary name + const functionData = setPrimaryName.makeFunctionData(walletClient, { + name: state.l1, + }) + const tx = await walletClient.sendTransaction({ + account, + ...functionData, + }) + await waitForTransaction(tx) + } + } + + // Set default primary name if specified + if (state.default !== undefined) { + const tx = await walletClient.sendTransaction({ + account, + to: deploymentAddresses.DefaultReverseRegistrar as Address, + data: encodeFunctionData({ + abi: defaultReverseRegistrarAbi, + functionName: 'setName', + args: [state.default], + }), + }) + await waitForTransaction(tx) + } +} + +/** + * Gets the current primary name state for a user from both registries. + */ +export async function getPrimaryNameState( + accounts: Accounts, + user: User, +): Promise { + const address = accounts.getAddress(user) as Address + const reverseNode = getReverseNode(address) + const reverseNodeHash = namehash(reverseNode) + + // Get L1 reverse registry name by querying the registry and resolver directly + let l1Name: string | null = null + try { + // First, get the resolver for the reverse node from the ENS Registry + const resolverAddress = await publicClient.readContract({ + address: deploymentAddresses.ENSRegistry as Address, + abi: registryResolverSnippet, + functionName: 'resolver', + args: [reverseNodeHash], + }) + + // If there's a resolver set, query the name from it + if (resolverAddress && resolverAddress !== '0x0000000000000000000000000000000000000000') { + const name = await publicClient.readContract({ + address: resolverAddress, + abi: resolverNameSnippet, + functionName: 'name', + args: [reverseNodeHash], + }) + l1Name = name || null + } + } catch { + l1Name = null + } + + // Get default reverse registry name + let defaultName: string | null = null + try { + const result = await publicClient.readContract({ + address: deploymentAddresses.DefaultReverseRegistrar as Address, + abi: defaultReverseRegistrarAbi, + functionName: 'nameForAddr', + args: [address], + }) + defaultName = result || null + } catch { + defaultName = null + } + + return { l1: l1Name, default: defaultName } +} + +/** + * Clears all primary names for a user (both L1 and default). + */ +export async function clearAllPrimaryNames(accounts: Accounts, user: User): Promise { + await setPrimaryNameState(accounts, { + user, + state: { l1: '', default: '' }, + }) +} + +export type { PrimaryNameState, SetPrimaryNameStateParams, GetPrimaryNameStateResult } diff --git a/playwright/index.ts b/playwright/index.ts index 5ab307cba..764fa7750 100644 --- a/playwright/index.ts +++ b/playwright/index.ts @@ -11,10 +11,17 @@ import { type Web3ProviderBackend, } from '@ensdomains/headless-web3-provider' -import { Accounts, createAccounts } from './fixtures/accounts' +import { Accounts, createAccounts, User } from './fixtures/accounts' import { createConsoleListener } from './fixtures/consoleListener' import { Login } from './fixtures/login' import { createMakeNames } from './fixtures/makeName/index.js' +import { + clearAllPrimaryNames, + getPrimaryNameState, + GetPrimaryNameStateResult, + PrimaryNameState, + setPrimaryNameState, +} from './fixtures/primaryName' import { createSubgraph } from './fixtures/subgraph.js' import { createTime } from './fixtures/time.js' import { createPageObjectMaker } from './pageObjects/index.js' @@ -34,6 +41,11 @@ type Fixtures = { subgraph: ReturnType time: ReturnType consoleListener: ReturnType + primaryName: { + setState: (params: { user: User; state: PrimaryNameState }) => Promise + getState: (user: User) => Promise + clearAll: (user: User) => Promise + } } const getChainById = (chain: string) => { @@ -85,4 +97,11 @@ export const test = base.extend({ await use(consoleListener) consoleListener.reset() }, + primaryName: async ({ accounts }, use) => { + await use({ + setState: (params) => setPrimaryNameState(accounts, params), + getState: (user) => getPrimaryNameState(accounts, user), + clearAll: (user) => clearAllPrimaryNames(accounts, user), + }) + }, }) diff --git a/src/components/pages/profile/settings/PrimarySection/PrimarySection.tsx b/src/components/pages/profile/settings/PrimarySection/PrimarySection.tsx index 2b71fa464..28144b65f 100644 --- a/src/components/pages/profile/settings/PrimarySection/PrimarySection.tsx +++ b/src/components/pages/profile/settings/PrimarySection/PrimarySection.tsx @@ -7,12 +7,12 @@ import { AvatarWithLink } from '@app/components/@molecules/AvatarWithLink/Avatar import { DisabledButtonWithTooltip } from '@app/components/@molecules/DisabledButtonWithTooltip' import { getNetworkFromUrl } from '@app/constants/chains' import { useAccountSafely } from '@app/hooks/account/useAccountSafely' +import { usePrimaryNameFromSources } from '@app/hooks/primary/usePrimaryNameFromSources' import { useBasicName } from '@app/hooks/useBasicName' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' import { useHasGraphError } from '@app/utils/SyncProvider/SyncProvider' import { NetworkSpecificPrimaryNamesSection } from './NetworkSpecificPrimaryNamesSection' -import { usePrimaryNameFromSources } from '@app/hooks/primary/usePrimaryNameFromSources' const SkeletonFiller = styled.div( ({ theme }) => css` diff --git a/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/index.ts b/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/index.ts index 492b754f7..2b51e29c5 100644 --- a/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/index.ts +++ b/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/index.ts @@ -9,6 +9,7 @@ import { createTransactionItem, TransactionItem } from '@app/transaction-flow/tr import { TransactionIntro } from '@app/transaction-flow/types' import { emptyAddress } from '@app/utils/constants' +import { usePrimaryNameFromSources } from '../usePrimaryNameFromSources' import { checkRequiresSetPrimaryNameTransaction, checkRequiresUpdateEthAddressTransaction, @@ -16,7 +17,6 @@ import { getIntroTranslation, IntroType, } from './utils' -import { usePrimaryNameFromSources } from '../usePrimaryNameFromSources' type Inputs = { address?: Address @@ -39,7 +39,7 @@ export const useGetPrimaryNameTransactionFlowItem = ( const _enabled = (options.enabled ?? true) && !!address - const { data: primaryNameDetails, isLoading, isFetching} = usePrimaryNameFromSources({ address }) + const { data: primaryNameDetails, isLoading, isFetching } = usePrimaryNameFromSources({ address }) const latestResolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) @@ -56,8 +56,13 @@ export const useGetPrimaryNameTransactionFlowItem = ( | TransactionItem<'updateEthAddress'> )[] = [] - const targetReverseRegistry = primaryNameDetails?.hasPrimaryName ? 'l1' : 'default' - const currentTargetReverseRegistryName = targetReverseRegistry === 'l1' ? primaryNameDetails?.reverseRegistryName : primaryNameDetails?.defaultReverseRegistryName + // Use default registry only if user has no L1 primary name + const canUseDefaultRegistry = !primaryNameDetails?.hasPrimaryName + const targetReverseRegistry = canUseDefaultRegistry ? 'default' : 'l1' + const currentTargetReverseRegistryName = + targetReverseRegistry === 'l1' + ? primaryNameDetails?.reverseRegistryName + : primaryNameDetails?.defaultReverseRegistryName if ( checkRequiresSetPrimaryNameTransaction({ @@ -65,11 +70,12 @@ export const useGetPrimaryNameTransactionFlowItem = ( name, }) ) { - if (targetReverseRegistry === 'default') transactions.push(createTransactionItem('setDefaultPrimaryName', { name, address })) - else transactions.push(createTransactionItem('setPrimaryName', { name, address})) + if (targetReverseRegistry === 'default') + transactions.push(createTransactionItem('setDefaultPrimaryName', { name, address })) + else transactions.push(createTransactionItem('setPrimaryName', { name, address })) } - if ( + if ( checkRequiresUpdateEthAddressTransaction({ resolvedAddress: profileAddress, address, @@ -104,8 +110,6 @@ export const useGetPrimaryNameTransactionFlowItem = ( ) } - - const introItem = transactions.length > 1 ? { @@ -141,6 +145,6 @@ export const useGetPrimaryNameTransactionFlowItem = ( return { callBack, isLoading, - isFetching + isFetching, } } diff --git a/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/useGetPrimaryNameTransactionFlowItem.test.ts b/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/useGetPrimaryNameTransactionFlowItem.test.ts index a92f40280..6f385a76c 100644 --- a/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/useGetPrimaryNameTransactionFlowItem.test.ts +++ b/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/useGetPrimaryNameTransactionFlowItem.test.ts @@ -4,14 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useContractAddress } from '@app/hooks/chain/useContractAddress' import { useResolverStatus } from '@app/hooks/resolver/useResolverStatus' -import { useReverseRegistryName } from '@app/hooks/ensjs/public/useReverseRegistryName' +import { usePrimaryNameFromSources } from '@app/hooks/primary/usePrimaryNameFromSources' import { useGetPrimaryNameTransactionFlowItem } from '.' -vi.mock('@app/hooks/ensjs/public/useReverseRegistryName') +vi.mock('@app/hooks/primary/usePrimaryNameFromSources') vi.mock('@app/hooks/chain/useContractAddress') -const mockUseReverseRegistryName = mockFunction(useReverseRegistryName) +const mockUsePrimaryNameFromSources = mockFunction(usePrimaryNameFromSources) const mockUseContractAddress = mockFunction(useContractAddress) const createResolverStatusData = ( @@ -26,8 +26,15 @@ const createResolverStatusData = ( describe('useGetPrimaryNameTransactionFlowItem', () => { beforeEach(() => { vi.clearAllMocks() - mockUseReverseRegistryName.mockReturnValue({ - data: 'test.eth', + mockUsePrimaryNameFromSources.mockReturnValue({ + data: { + name: 'test.eth', + hasPrimaryName: true, + hasDefaultPrimaryName: false, + reverseRegistryName: 'test.eth', + defaultReverseRegistryName: undefined, + source: 'l1', + }, isLoading: false, isFetching: false, }) @@ -48,8 +55,15 @@ describe('useGetPrimaryNameTransactionFlowItem', () => { }) it('should return transaction SetPrimaryName if the reverseRegistryName is undefined.', async () => { - mockUseReverseRegistryName.mockReturnValue({ - data: undefined, + mockUsePrimaryNameFromSources.mockReturnValue({ + data: { + name: undefined, + hasPrimaryName: false, + hasDefaultPrimaryName: false, + reverseRegistryName: undefined, + defaultReverseRegistryName: undefined, + source: null, + }, isLoading: false, isFetching: false, }) @@ -69,7 +83,7 @@ describe('useGetPrimaryNameTransactionFlowItem', () => { name: 'test.eth', address: '0x123', }, - name: 'setPrimaryName', + name: 'setDefaultPrimaryName', }, ], }) @@ -156,17 +170,17 @@ describe('useGetPrimaryNameTransactionFlowItem', () => { { data: { name: 'test.eth', - address: '0x123', - latestResolver: true, + contract: 'registry', }, - name: 'updateEthAddress', + name: 'updateResolver', }, { data: { name: 'test.eth', - contract: 'registry', + address: '0x123', + latestResolver: true, }, - name: 'updateResolver', + name: 'updateEthAddress', }, ], }) diff --git a/src/hooks/primary/usePrimaryNameFromSources.ts b/src/hooks/primary/usePrimaryNameFromSources.ts index e01e7bd68..62c7b5f64 100644 --- a/src/hooks/primary/usePrimaryNameFromSources.ts +++ b/src/hooks/primary/usePrimaryNameFromSources.ts @@ -1,10 +1,10 @@ +import { match, P } from 'ts-pattern' import { Address } from 'viem' import { useDefaultReverseRegistryName } from '@app/hooks/ensjs/public/useDefaultReverseRegistryName' import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import { useReverseRegistryName } from '@app/hooks/ensjs/public/useReverseRegistryName' import { emptyAddress } from '@app/utils/constants' -import { match, P } from 'ts-pattern' type PrimaryNameSource = 'l1' | 'default' | null @@ -32,7 +32,6 @@ export const usePrimaryNameFromSources = ({ enabled: isEnabled && hasPrimaryNameResolved, }) const hasPrimaryName = !!reverseRegistryName.data -   const defaultReverseRegistryName = useDefaultReverseRegistryName({ address, enabled: isEnabled && hasPrimaryNameResolved, @@ -46,25 +45,49 @@ export const usePrimaryNameFromSources = ({ hasDefaultPrimaryName, reverseRegistryNameData: reverseRegistryName?.data, defaultReverseRegistryNameData: defaultReverseRegistryName?.data, - name + name, }) .with( - { name: P.string, hasPrimaryName: true, reverseRegistryNameData: P.when((data) => data === name) }, + { + name: P.string, + hasPrimaryName: true, + reverseRegistryNameData: P.when((data) => data === name), + }, () => 'l1' as const, ) - .with({ name: P.string, hasDefaultPrimaryName: true, - defaultReverseRegistryNameData: P.when((data) => data === name) - }, () => 'default' as const) + .with( + { + name: P.string, + hasDefaultPrimaryName: true, + defaultReverseRegistryNameData: P.when((data) => data === name), + }, + () => 'default' as const, + ) .otherwise(() => null) - const isLoading = [primaryName, reverseRegistryName, defaultReverseRegistryName].some(({isLoading}) => isLoading) - const isFetching = [primaryName, reverseRegistryName, defaultReverseRegistryName].some(({ isFetching}) => isFetching) - const secondaryQueriesSettled = !hasPrimaryNameResolved || (reverseRegistryName.isSuccess || defaultReverseRegistryName.isSuccess) + const isLoading = [primaryName, reverseRegistryName, defaultReverseRegistryName].some( + (query) => query.isLoading, + ) + const isFetching = [primaryName, reverseRegistryName, defaultReverseRegistryName].some( + (query) => query.isFetching, + ) + const secondaryQueriesSettled = + !hasPrimaryNameResolved || reverseRegistryName.isSuccess || defaultReverseRegistryName.isSuccess const isSuccess = primaryName.isSuccess && secondaryQueriesSettled const error = primaryName.error || reverseRegistryName.error || defaultReverseRegistryName.error - const data = primaryName.data !== undefined ? { ...primaryName.data, hasDefaultPrimaryName, hasPrimaryName, source, reverseRegistryName: reverseRegistryName.data, defaultReverseRegistryName: defaultReverseRegistryName.data } : undefined + const data = + primaryName.data !== undefined + ? { + ...primaryName.data, + hasDefaultPrimaryName, + hasPrimaryName, + source, + reverseRegistryName: reverseRegistryName.data, + defaultReverseRegistryName: defaultReverseRegistryName.data, + } + : undefined return { ...primaryName, diff --git a/src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName.test.tsx b/src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName.test.tsx index e3b73f227..bd2d84fe0 100644 --- a/src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName.test.tsx +++ b/src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName.test.tsx @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getDecodedName } from '@ensdomains/ensjs/subgraph' import { decodeLabelhash } from '@ensdomains/ensjs/utils' -import { useReverseRegistryName } from '@app/hooks/ensjs/public/useReverseRegistryName' +import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import { useNamesForAddress } from '@app/hooks/ensjs/subgraph/useNamesForAddress' import { useGetPrimaryNameTransactionFlowItem } from '@app/hooks/primary/useGetPrimaryNameTransactionFlowItem' import { useResolverStatus } from '@app/hooks/resolver/useResolverStatus' @@ -39,10 +39,10 @@ vi.mock('@app/hooks/resolver/useResolverStatus') vi.mock('@app/hooks/useIsWrapped') vi.mock('@app/hooks/useProfile') vi.mock('@app/hooks/primary/useGetPrimaryNameTransactionFlowItem') -vi.mock('@app/hooks/ensjs/public/useReverseRegistryName') +vi.mock('@app/hooks/ensjs/public/usePrimaryName') const mockGetDecodedName = mockFunction(getDecodedName) -const mockUseReverseRegistryName = mockFunction(useReverseRegistryName) +const mockUsePrimaryName = mockFunction(usePrimaryName) mockGetDecodedName.mockImplementation((_: any, { name }) => Promise.resolve(name)) const makeName = (index: number, overwrites?: any) => ({ @@ -212,10 +212,13 @@ describe('SelectPrimaryName', () => { }) it('should not show primary name in list', async () => { - mockUseReverseRegistryName.mockReturnValue({ - data: 'test2.eth', + mockUsePrimaryName.mockReturnValue({ + data: { + name: 'test2.eth', + beautifiedName: 'test2.eth', + match: true, + }, isLoading: false, - status: 'success', }) render( {}} onDismiss={() => {}} />, From 8893942e9a5b6d728e67b572e29b2b5d92591581 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Mon, 19 Jan 2026 14:19:00 +0800 Subject: [PATCH 3/6] Update settings.local.json --- .claude/settings.local.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7b8d6b724..74bbc2b0c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -44,7 +44,7 @@ ] }, "sandbox": { - "enabled": true, + "enabled": false, "autoAllowBashIfSandboxed": true, "allowUnsandboxedCommands": false, "network": { From 27f8dcf755ad626cc806cbd0c295c5648d250a26 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Mon, 19 Jan 2026 14:53:39 +0800 Subject: [PATCH 4/6] fix: replace ensjs/wallet imports with direct contract calls in primaryName fixture Remove dependency on @ensdomains/ensjs/wallet and @ensdomains/ensjs/contracts imports which cause ESM compatibility issues with Playwright's CommonJS runtime. Replace with inline ABI definitions and direct contract calls using viem's encodeFunctionData. Co-Authored-By: Claude Opus 4.5 --- playwright/fixtures/primaryName.ts | 41 +++++++++++++++++++----------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/playwright/fixtures/primaryName.ts b/playwright/fixtures/primaryName.ts index 41703e21b..68a8719dc 100644 --- a/playwright/fixtures/primaryName.ts +++ b/playwright/fixtures/primaryName.ts @@ -1,9 +1,6 @@ /* eslint-disable import/no-extraneous-dependencies */ import { Address, encodeFunctionData, namehash, parseAbi } from 'viem' -import { registryResolverSnippet } from '@ensdomains/ensjs/contracts' -import { setPrimaryName, setResolver } from '@ensdomains/ensjs/wallet' - import { Accounts, User } from './accounts' import { deploymentAddresses, @@ -18,6 +15,17 @@ const defaultReverseRegistrarAbi = parseAbi([ 'function nameForAddr(address addr) external view returns (string)', ]) +// ABI for L1 ReverseRegistrar contract +const reverseRegistrarAbi = parseAbi(['function setName(string name) external returns (bytes32)']) + +// ABI for ENS Registry setResolver function +const registrySetResolverAbi = parseAbi([ + 'function setResolver(bytes32 node, address resolver) external', +]) + +// ABI for ENS Registry resolver function +const registryResolverAbi = parseAbi(['function resolver(bytes32 node) external view returns (address)']) + // ABI for resolver name() function const resolverNameSnippet = parseAbi([ 'function name(bytes32 node) external view returns (string)', @@ -72,24 +80,27 @@ export async function setPrimaryNameState( if (state.l1 !== undefined) { if (state.l1 === '') { // To clear L1 primary name, set the resolver for the reverse node to zero address - const functionData = setResolver.makeFunctionData(walletClient, { - name: getReverseNode(address), - contract: 'registry', - resolverAddress: '0x0000000000000000000000000000000000000000', - }) + const reverseNodeHash = namehash(getReverseNode(address)) const tx = await walletClient.sendTransaction({ account, - ...functionData, + to: deploymentAddresses.ENSRegistry as Address, + data: encodeFunctionData({ + abi: registrySetResolverAbi, + functionName: 'setResolver', + args: [reverseNodeHash, '0x0000000000000000000000000000000000000000'], + }), }) await waitForTransaction(tx) } else { - // Set the primary name - const functionData = setPrimaryName.makeFunctionData(walletClient, { - name: state.l1, - }) + // Set the primary name using the L1 ReverseRegistrar const tx = await walletClient.sendTransaction({ account, - ...functionData, + to: deploymentAddresses.ReverseRegistrar as Address, + data: encodeFunctionData({ + abi: reverseRegistrarAbi, + functionName: 'setName', + args: [state.l1], + }), }) await waitForTransaction(tx) } @@ -127,7 +138,7 @@ export async function getPrimaryNameState( // First, get the resolver for the reverse node from the ENS Registry const resolverAddress = await publicClient.readContract({ address: deploymentAddresses.ENSRegistry as Address, - abi: registryResolverSnippet, + abi: registryResolverAbi, functionName: 'resolver', args: [reverseNodeHash], }) From 118a3433d3a4dd4d59914c1bd7da6020d3573fc2 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Mon, 19 Jan 2026 15:16:10 +0800 Subject: [PATCH 5/6] fix: check for existing resolver before clearing L1 primary name When clearing the L1 primary name via setPrimaryNameState, the code would attempt to call setResolver on the ENS Registry even when no resolver existed. This caused transaction reverts because the user doesn't own the reverse node until a primary name is set. Now queries the current resolver first and only attempts to clear it if one exists, preventing errors when clearAll is called before any L1 primary name has been set. --- playwright/fixtures/primaryName.ts | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/playwright/fixtures/primaryName.ts b/playwright/fixtures/primaryName.ts index 68a8719dc..a25f1559f 100644 --- a/playwright/fixtures/primaryName.ts +++ b/playwright/fixtures/primaryName.ts @@ -80,17 +80,28 @@ export async function setPrimaryNameState( if (state.l1 !== undefined) { if (state.l1 === '') { // To clear L1 primary name, set the resolver for the reverse node to zero address + // First check if there's a resolver set - if not, nothing to clear const reverseNodeHash = namehash(getReverseNode(address)) - const tx = await walletClient.sendTransaction({ - account, - to: deploymentAddresses.ENSRegistry as Address, - data: encodeFunctionData({ - abi: registrySetResolverAbi, - functionName: 'setResolver', - args: [reverseNodeHash, '0x0000000000000000000000000000000000000000'], - }), + const currentResolver = await publicClient.readContract({ + address: deploymentAddresses.ENSRegistry as Address, + abi: registryResolverAbi, + functionName: 'resolver', + args: [reverseNodeHash], }) - await waitForTransaction(tx) + + // Only attempt to clear if there's a resolver set + if (currentResolver && currentResolver !== '0x0000000000000000000000000000000000000000') { + const tx = await walletClient.sendTransaction({ + account, + to: deploymentAddresses.ENSRegistry as Address, + data: encodeFunctionData({ + abi: registrySetResolverAbi, + functionName: 'setResolver', + args: [reverseNodeHash, '0x0000000000000000000000000000000000000000'], + }), + }) + await waitForTransaction(tx) + } } else { // Set the primary name using the L1 ReverseRegistrar const tx = await walletClient.sendTransaction({ From 273bef4921ed24fe54e73d8dfe4c478d16569203 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Mon, 19 Jan 2026 19:30:50 +0800 Subject: [PATCH 6/6] refactor: use setPrimaryNameState fixture in setPrimary tests Replace direct ensjs/wallet setPrimaryName calls with the setPrimaryNameState fixture function for consistency across tests. Update test expectations to reflect default registry behavior when no L1 primary name exists. --- e2e/specs/stateless/setPrimary.spec.ts | 58 +++++++------------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/e2e/specs/stateless/setPrimary.spec.ts b/e2e/specs/stateless/setPrimary.spec.ts index 8237984eb..528384091 100644 --- a/e2e/specs/stateless/setPrimary.spec.ts +++ b/e2e/specs/stateless/setPrimary.spec.ts @@ -2,22 +2,17 @@ import { expect } from '@playwright/test' import { labelhash } from 'viem' import { getResolver } from '@ensdomains/ensjs/public' -import { setPrimaryName } from '@ensdomains/ensjs/wallet' import { test } from '../../../playwright' import { createAccounts } from '../../../playwright/fixtures/accounts' -import { - waitForTransaction, - walletClient, -} from '../../../playwright/fixtures/contracts/utils/addTestContracts' +import { walletClient } from '../../../playwright/fixtures/contracts/utils/addTestContracts' +import { setPrimaryNameState } from '../../../playwright/fixtures/primaryName' const UNAUTHORISED_RESOLVER = '0xd7a4F6473f32aC2Af804B3686AE8F1932bC35750' test.afterAll(async () => { - await setPrimaryName(walletClient, { - name: '', - account: createAccounts().getAddress('user') as `0x${string}`, - }) + const accounts = createAccounts() + await setPrimaryNameState(accounts, { user: 'user', state: { l1: '', default: '' } }) }) test.describe('profile', () => { @@ -46,10 +41,7 @@ test.describe('profile', () => { addr: 'user', }) - await setPrimaryName(walletClient, { - name: '', - account: createAccounts().getAddress('user') as `0x${string}`, - }) + await setPrimaryNameState(accounts, { user: 'user', state: { l1: '', default: '' } }) const profilePage = makePageObject('ProfilePage') const transactionModal = makePageObject('TransactionModal') @@ -71,9 +63,9 @@ test.describe('profile', () => { ) await page.getByText('Set as primary name').click() - // Transaction modal + // Transaction modal - uses default registry when no L1 primary name exists await expect(page.getByTestId('display-item-info-normal')).toContainText( - 'Set the primary name for your address', + 'Set the default primary name for your address', ) await expect(page.getByTestId('display-item-name-normal')).toContainText(name) await expect(page.getByTestId('display-item-address-normal')).toContainText(/0xf39...92266/) @@ -110,10 +102,7 @@ test.describe('profile', () => { }) => { test.slow() - await setPrimaryName(walletClient, { - name: '', - account: createAccounts().getAddress('user') as `0x${string}`, - }) + await setPrimaryNameState(accounts, { user: 'user', state: { l1: '', default: '' } }) const name = await makeName({ label: 'other-eth-record', @@ -157,10 +146,7 @@ test.describe('profile', () => { }) => { test.slow() - await setPrimaryName(walletClient, { - name: '', - account: createAccounts().getAddress('user') as `0x${string}`, - }) + await setPrimaryNameState(accounts, { user: 'user', state: { l1: '', default: '' } }) const name = await makeName({ label: 'wrapped', @@ -215,10 +201,7 @@ test.describe('profile', () => { }) => { test.slow() - await setPrimaryName(walletClient, { - name: '', - account: createAccounts().getAddress('user') as `0x${string}`, - }) + await setPrimaryNameState(accounts, { user: 'user', state: { l1: '', default: '' } }) const name = await makeName({ label: 'wrapped', @@ -287,6 +270,7 @@ test.describe('profile', () => { test('should skip setting primary name step if reverse registry name is already set to that name', async ({ page, login, + accounts, makeName, makePageObject, }) => { @@ -306,11 +290,8 @@ test.describe('profile', () => { }) const subname = `test.${name}` - const tx = await setPrimaryName(walletClient, { - name: subname, - account: createAccounts().getAddress('user') as `0x${string}`, - }) - await waitForTransaction(tx) + // Set L1 primary name first to test that the primary name step is skipped + await setPrimaryNameState(accounts, { user: 'user', state: { l1: subname } }) const profilePage = makePageObject('ProfilePage') const transactionModal = makePageObject('TransactionModal') @@ -396,12 +377,7 @@ test.describe('profile', () => { }) => { test.slow() - const tx = await setPrimaryName(walletClient, { - name: '', - account: createAccounts().getAddress('user') as `0x${string}`, - }) - await waitForTransaction(tx) - console.log('tx', tx) + await setPrimaryNameState(accounts, { user: 'user', state: { l1: '', default: '' } }) const label = `unknown-label-${Date.now()}` const _labelhash = labelhash(label) @@ -454,11 +430,7 @@ test.describe('profile', () => { }) => { test.slow() - const tx = await setPrimaryName(walletClient, { - name: '', - account: createAccounts().getAddress('user') as `0x${string}`, - }) - await waitForTransaction(tx) + await setPrimaryNameState(accounts, { user: 'user', state: { l1: '', default: '' } }) const name = await makeName({ label: 'legacy',