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": { 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/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/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', 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..a25f1559f --- /dev/null +++ b/playwright/fixtures/primaryName.ts @@ -0,0 +1,198 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { Address, encodeFunctionData, namehash, parseAbi } from 'viem' + +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 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)', +]) + +// 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 + // First check if there's a resolver set - if not, nothing to clear + const reverseNodeHash = namehash(getReverseNode(address)) + const currentResolver = await publicClient.readContract({ + address: deploymentAddresses.ENSRegistry as Address, + abi: registryResolverAbi, + functionName: 'resolver', + args: [reverseNodeHash], + }) + + // 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({ + account, + to: deploymentAddresses.ReverseRegistrar as Address, + data: encodeFunctionData({ + abi: reverseRegistrarAbi, + functionName: 'setName', + args: [state.l1], + }), + }) + 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: registryResolverAbi, + 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/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..28144b65f 100644 --- a/src/components/pages/profile/settings/PrimarySection/PrimarySection.tsx +++ b/src/components/pages/profile/settings/PrimarySection/PrimarySection.tsx @@ -7,8 +7,7 @@ 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 { 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' @@ -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/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={() => {}} />, 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