diff --git a/e2e/specs/stateless/registerName.spec.ts b/e2e/specs/stateless/registerName.spec.ts index 38d86e3d5..812e07612 100644 --- a/e2e/specs/stateless/registerName.spec.ts +++ b/e2e/specs/stateless/registerName.spec.ts @@ -1,7 +1,10 @@ import { expect } from '@playwright/test' -import { Hash, isHash } from 'viem' +import { Hash } from 'viem' -import { ethRegistrarControllerCommitSnippet } from '@ensdomains/ensjs/contracts' +import { + ethRegistrarControllerCommitSnippet, + legacyEthRegistrarControllerCommitSnippet, +} from '@ensdomains/ensjs/contracts' import { setPrimaryName } from '@ensdomains/ensjs/wallet' import { Web3RequestKind } from '@ensdomains/headless-web3-provider' @@ -23,7 +26,7 @@ import { test.describe.serial('normal registration', () => { const name = `registration-normal-${Date.now()}.eth` - test('should allow normal registration', async ({ + test('should allow normal registration, if primary name is set, name is wrapped', async ({ page, login, accounts, @@ -233,8 +236,11 @@ test.describe.serial('normal registration', () => { await expect(page.getByText(`You are now the owner of ${name}`)).toBeVisible() // calculate date one year from now - const date = new Date() - date.setFullYear(date.getFullYear() + 1) + const date = await page.evaluate(() => { + const _date = new Date() + _date.setFullYear(_date.getFullYear() + 1) + return _date + }) const formattedDate = date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', @@ -255,6 +261,10 @@ test.describe.serial('normal registration', () => { accounts.getAddress('user', 5), ) }) + + await test.step('confirm name is wrapped', async () => { + await expect(page.getByTestId('permissions-tab')).toBeVisible() + }) }) test('should not direct to the registration page on search, and show all records from registration', async ({ @@ -285,7 +295,7 @@ test.describe.serial('normal registration', () => { ) }) - test('should allow registering a non-primary name', async ({ + test('registering a non-primary name should not be wrapped', async ({ page, accounts, time, @@ -326,10 +336,67 @@ test.describe.serial('normal registration', () => { await expect(page.getByTestId('address-profile-button-eth')).toHaveText( new RegExp(accounts.getAddress('user', 5)), ) + + await test.step('confirm name is unwrapped', async () => { + await expect(page.getByTestId('permissions-tab')).not.toBeVisible() + }) + }) +}) + +test('registering a premium name with no records should not be wrapped', async ({ + page, + login, + accounts, + makeName, + makePageObject, +}) => { + const premiumName = await makeName( + { + label: 'premium', + owner: 'user2', + type: 'legacy', + duration: -7890000 - 4 * 345600, // 3 months 4 days + }, + { timeOffset: 500 }, + ) + + const transactionModal = makePageObject('TransactionModal') + + await page.goto(`/${premiumName}/register`) + await login.connect() + + await page.getByTestId('primary-name-toggle').uncheck() + await page.getByTestId('payment-choice-ethereum').click() + await expect(page.getByTestId('invoice-item-2-amount')).toBeVisible() + await page.getByTestId('next-button').click() + if (await page.getByTestId('profile-submit-button').isVisible()) { + await page.getByTestId('profile-submit-button').click() + } + + await page.getByTestId('next-button').click() + await transactionModal.confirm() + + await expect(page.getByTestId('countdown-complete-check')).toBeVisible() + await testClient.increaseTime({ seconds: 120 }) + await page.getByTestId('finish-button').click() + await transactionModal.confirm() + + await page.getByTestId('view-name').click() + await expect(page.getByTestId('address-profile-button-eth')).toHaveText( + new RegExp(accounts.getAddress('user', 5)), + ) + + await test.step('confirm name is unwrapped', async () => { + await expect(page.getByTestId('permissions-tab')).not.toBeVisible() }) + + const morePage = makePageObject('MorePage') + await morePage.goto(premiumName) + + await expect(morePage.wrapButton).toBeVisible() }) -test('should allow registering a premium name', async ({ +test('registering a premium name with primary name not set should not be wrapped', async ({ page, login, accounts, @@ -346,11 +413,200 @@ test('should allow registering a premium name', async ({ { timeOffset: 500 }, ) + await setPrimaryName(walletClient, { + name: 'premium', + account: createAccounts().getAddress('user2') as `0x${string}`, + }) + + const transactionModal = makePageObject('TransactionModal') + + await page.goto(`/${premiumName}/register`) + await login.connect() + + await page.getByTestId('payment-choice-ethereum').click() + await expect(page.getByTestId('invoice-item-2-amount')).toBeVisible() + await page.getByTestId('next-button').click() + if (await page.getByTestId('profile-submit-button').isVisible()) { + await page.getByTestId('profile-submit-button').click() + } + + await page.getByTestId('next-button').click() + await transactionModal.confirm() + + await expect(page.getByTestId('countdown-complete-check')).toBeVisible() + await testClient.increaseTime({ seconds: 120 }) + await page.getByTestId('finish-button').click() + await transactionModal.confirm() + + await page.getByTestId('view-name').click() + await expect(page.getByTestId('address-profile-button-eth')).toHaveText( + new RegExp(accounts.getAddress('user', 5)), + ) + + await test.step('confirm name is wrapped', async () => { + await expect(page.getByTestId('permissions-tab')).not.toBeVisible() + }) + + const morePage = makePageObject('MorePage') + await morePage.goto(premiumName) + await expect(morePage.wrapButton).toBeVisible() + + const recordsPage = makePageObject('RecordsPage') + await recordsPage.goto(premiumName) + + await expect(recordsPage.getRecordValue('address', 'ETH')).toHaveText( + createAccounts().getAddress('user') as `0x${string}`, + ) +}) + +test('registering a premium name with primary name set should be wrapped', async ({ + page, + login, + accounts, + makeName, + makePageObject, +}) => { + const premiumName = await makeName( + { + label: 'premium', + owner: 'user2', + type: 'legacy', + duration: -7890000 - 4 * 345600, // 3 months 4 days + }, + { timeOffset: 500 }, + ) + + await setPrimaryName(walletClient, { + name: 'premium', + account: createAccounts().getAddress('user') as `0x${string}`, + }) + + const transactionModal = makePageObject('TransactionModal') + + await page.goto(`/${premiumName}/register`) + await login.connect() + + await page.getByTestId('payment-choice-ethereum').click() + await expect(page.getByTestId('invoice-item-2-amount')).toBeVisible() + await page.getByTestId('next-button').click() + if (await page.getByTestId('profile-submit-button').isVisible()) { + await page.getByTestId('profile-submit-button').click() + } + + await page.getByTestId('next-button').click() + await transactionModal.confirm() + + await expect(page.getByTestId('countdown-complete-check')).toBeVisible() + await testClient.increaseTime({ seconds: 120 }) + await page.getByTestId('finish-button').click() + await transactionModal.confirm() + + await page.getByTestId('view-name').click() + await expect(page.getByTestId('address-profile-button-eth')).toHaveText( + new RegExp(accounts.getAddress('user', 5)), + ) + + await test.step('confirm name is wrapped', async () => { + await expect(page.getByTestId('permissions-tab')).toBeVisible() + }) + + const morePage = makePageObject('MorePage') + await morePage.goto(premiumName) + await expect(morePage.wrapButton).toHaveCount(0) + + const recordsPage = makePageObject('RecordsPage') + await recordsPage.goto(premiumName) + + await expect(recordsPage.getRecordValue('address', 'ETH')).toHaveText( + createAccounts().getAddress('user') as `0x${string}`, + ) +}) + +test('registering a premium name with existing records should not be wrapped', async ({ + page, + login, + accounts, + makeName, + makePageObject, +}) => { + const premiumName = await makeName( + { + label: 'premium', + owner: 'user2', + type: 'legacy', + records: { + coins: [ + { + coin: 'etcLegacy', + value: '0x42D63ae25990889E35F215bC95884039Ba354115', + }, + { + coin: 'ETH', + value: createAccounts().getAddress('user') as `0x${string}`, + }, + ], + }, + duration: -7890000 - 4 * 345600, // 3 months 4 days + }, + { timeOffset: 500 }, + ) + + const transactionModal = makePageObject('TransactionModal') + + await page.goto(`/${premiumName}/register`) + await login.connect() + + await page.getByTestId('payment-choice-ethereum').click() + await expect(page.getByTestId('invoice-item-2-amount')).toBeVisible() + await page.getByTestId('next-button').click() + if (await page.getByTestId('profile-submit-button').isVisible()) { + await page.getByTestId('profile-submit-button').click() + } + + await page.getByTestId('next-button').click() + await transactionModal.confirm() + + await expect(page.getByTestId('countdown-complete-check')).toBeVisible() + await testClient.increaseTime({ seconds: 120 }) + await page.getByTestId('finish-button').click() + await transactionModal.confirm() + + await page.getByTestId('view-name').click() + await expect(page.getByTestId('address-profile-button-eth')).toHaveText( + new RegExp(accounts.getAddress('user', 5)), + ) + + const recordsPage = makePageObject('RecordsPage') + await recordsPage.goto(premiumName) + + await expect(recordsPage.getRecordValue('address', 'ETH')).toHaveText( + createAccounts().getAddress('user') as `0x${string}`, + ) +}) + +test('registering a wrapped premium name with no records should not be wrapped', async ({ + page, + login, + accounts, + makeName, + makePageObject, +}) => { + const premiumName = await makeName( + { + label: 'premium', + owner: 'user2', + type: 'wrapped', + duration: -7890000 - 4 * 345600, // 3 months 4 days + }, + { timeOffset: 500 }, + ) + const transactionModal = makePageObject('TransactionModal') await page.goto(`/${premiumName}/register`) await login.connect() + await page.getByTestId('primary-name-toggle').uncheck() await page.getByTestId('payment-choice-ethereum').click() await expect(page.getByTestId('invoice-item-2-amount')).toBeVisible() await page.getByTestId('next-button').click() @@ -370,6 +626,103 @@ test('should allow registering a premium name', async ({ await expect(page.getByTestId('address-profile-button-eth')).toHaveText( new RegExp(accounts.getAddress('user', 5)), ) + + await test.step('confirm name is unwrapped', async () => { + await expect(page.getByTestId('permissions-tab')).not.toBeVisible() + }) + + const morePage = makePageObject('MorePage') + await morePage.goto(premiumName) + + await expect(morePage.wrapButton).toBeVisible() + + const recordsPage = makePageObject('RecordsPage') + await recordsPage.goto(premiumName) + + await expect(recordsPage.getRecordValue('address', 'ETH')).toHaveText( + createAccounts().getAddress('user') as `0x${string}`, + ) +}) + +test('registering a wrapped premium name with records set should be wrapped', async ({ + page, + login, + accounts, + makeName, + makePageObject, +}) => { + const premiumName = await makeName( + { + label: 'premium', + owner: 'user', + type: 'wrapped', + duration: -7890000 - 4 * 345600, // 3 months 4 days + records: { + coins: [ + { + coin: 'ETH', + value: createAccounts().getAddress('user') as `0x${string}`, + }, + { + coin: 'etcLegacy', + value: '0x42D63ae25990889E35F215bC95884039Ba354115', + }, + ], + }, + }, + { timeOffset: 500 }, + ) + + const transactionModal = makePageObject('TransactionModal') + + await page.goto(`/${premiumName}/register`) + await login.connect() + + await page.getByTestId('primary-name-toggle').uncheck() + await page.getByTestId('payment-choice-ethereum').click() + await expect(page.getByTestId('invoice-item-2-amount')).toBeVisible() + await page.getByTestId('next-button').click() + + await page.getByTestId('setup-profile-button').click() + + await test.step('add a text record', async () => { + await page.getByTestId('show-add-profile-records-modal-button').click() + await page.getByTestId('confirmation-dialog-confirm-button').click() + await page.getByTestId('profile-record-option-name').click() + await page.getByTestId('add-profile-records-button').click() + await page.getByTestId('profile-record-input-input-name').fill('Test Name') + await page.getByTestId('profile-submit-button').click() + }) + + await page.getByTestId('next-button').click() + await transactionModal.confirm() + + await expect(page.getByTestId('countdown-complete-check')).toBeVisible() + await testClient.increaseTime({ seconds: 120 }) + await page.getByTestId('finish-button').click() + await transactionModal.confirm() + + await page.getByTestId('view-name').click() + + await expect(page.getByTestId('address-profile-button-eth')).toHaveText( + new RegExp(accounts.getAddress('user', 5)), + ) + + await test.step('confirm name is wrapped', async () => { + await expect(page.getByTestId('permissions-tab')).toBeVisible() + }) + + const morePage = makePageObject('MorePage') + await morePage.goto(premiumName) + await expect(morePage.wrapButton).toHaveCount(0) + + const recordsPage = makePageObject('RecordsPage') + await recordsPage.goto(premiumName) + + await expect(recordsPage.getRecordValue('text', 'name')).toHaveText('Test Name') + await expect(recordsPage.getRecordValue('address', 'ETH')).toHaveText( + createAccounts().getAddress('user') as `0x${string}`, + ) }) test('should allow registering a name and resuming from the commit toast', async ({ @@ -458,9 +811,9 @@ test('should allow registering with a specific date', async ({ page, login, make await expect(page.getByTestId('payment-choice-ethereum')).toBeChecked() await expect(registrationPage.primaryNameToggle).not.toBeChecked() - await test.step('should show correct price data (for 2.5 years)', async () => { - await expect(registrationPage.yearMarker(0)).toHaveText(/11% gas/) - await expect(registrationPage.yearMarker(1)).toHaveText(/6% gas/) + await test.step('should show correct price marker data for unwrapped registration (for 2.5 years)', async () => { + await expect(registrationPage.yearMarker(0)).toHaveText(/9% gas/) + await expect(registrationPage.yearMarker(1)).toHaveText(/5% gas/) await expect(registrationPage.yearMarker(2)).toHaveText(/2% gas/) }) }) @@ -515,6 +868,8 @@ test('should allow registering a premium name with a specific date', async ({ }) await page.getByTestId('payment-choice-ethereum').click() + await page.getByTestId('primary-name-toggle').check() + await expect(page.getByTestId('invoice-item-2-amount')).toBeVisible() await page.getByTestId('next-button').click() if (await page.getByTestId('profile-submit-button').isVisible()) { @@ -533,6 +888,10 @@ test('should allow registering a premium name with a specific date', async ({ await expect(page.getByTestId('address-profile-button-eth')).toHaveText( new RegExp(accounts.getAddress('user', 5)), ) + + await test.step('confirm name is wrapped', async () => { + await expect(page.getByTestId('permissions-tab')).toBeVisible() + }) }) test('should allow registering a premium name for two months', async ({ @@ -601,6 +960,10 @@ test('should allow registering a premium name for two months', async ({ await expect(page.getByTestId('address-profile-button-eth')).toHaveText( new RegExp(accounts.getAddress('user', 5)), ) + + await test.step('confirm name is unwrapped', async () => { + await expect(page.getByTestId('permissions-tab')).not.toBeVisible() + }) }) test('should not allow registering a premium name for less than 28 days', async ({ @@ -680,6 +1043,10 @@ test('should not allow registering a premium name for less than 28 days', async await expect(page.getByTestId('address-profile-button-eth')).toHaveText( new RegExp(accounts.getAddress('user', 5)), ) + + await test.step('confirm name is unwrapped', async () => { + await expect(page.getByTestId('permissions-tab')).not.toBeVisible() + }) }) test('should allow normal registration for a month', async ({ @@ -800,6 +1167,10 @@ test('should allow normal registration for a month', async ({ await expect(page.getByTestId('address-profile-button-eth')).toHaveText( accounts.getAddress('user', 5), ) + + await test.step('confirm name is wrapped', async () => { + await expect(page.getByTestId('permissions-tab')).toBeVisible() + }) }) test('should not allow normal registration less than 28 days', async ({ @@ -931,14 +1302,19 @@ test('should not allow normal registration less than 28 days', async ({ await expect(page.getByTestId('address-profile-button-eth')).toHaveText( accounts.getAddress('user', 5), ) + + await test.step('confirm name is wrapped (set primary name)', async () => { + await expect(page.getByTestId('permissions-tab')).toBeVisible() + }) }) -test('should be able to detect an existing commit created on a private mempool', async ({ +test('should be able to detect an existing commit created on a private mempool for a wrapped registration', async ({ page, login, accounts, time, wallet, + consoleListener, makePageObject, }) => { test.slow() @@ -947,6 +1323,9 @@ test('should be able to detect an existing commit created on a private mempool', const homePage = makePageObject('HomePage') const registrationPage = makePageObject('RegistrationPage') const transactionModal = makePageObject('TransactionModal') + await consoleListener.initialize({ + regex: /commit is:/, + }) await time.sync(500) @@ -958,28 +1337,20 @@ test('should be able to detect an existing commit created on a private mempool', await homePage.searchInput.press('Enter') await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() - await registrationPage.primaryNameToggle.uncheck() + await registrationPage.primaryNameToggle.check() // should go to profile editor step await page.getByTestId('next-button').click() + await page.getByTestId('profile-submit-button').click() + await test.step('should be able to find an existing commit', async () => { await page.getByTestId('next-button').click() await transactionModal.closeButton.click() - let commitHash: Hash | undefined - let attempts = 0 - while (!commitHash && attempts < 4) { - // eslint-disable-next-line no-await-in-loop - const message = await page.waitForEvent('console') - // eslint-disable-next-line no-await-in-loop - const txt = await message.text() - const hash = txt.split(':')[1]?.trim() as Hash - if (isHash(hash)) commitHash = hash - attempts += 1 - } - expect(commitHash!).toBeDefined() + await expect(consoleListener.getMessages().length).toBeGreaterThan(0) + const commitHash = consoleListener.getMessages()[0].split(':')[1]?.trim() as Hash const approveTx = await walletClient.writeContract({ abi: ethRegistrarControllerCommitSnippet, @@ -1025,15 +1396,116 @@ test('should be able to detect an existing commit created on a private mempool', await expect(transactionModal.transactionModal).toHaveCount(0) await wallet.authorize(Web3RequestKind.SendTransaction) - // await transactionModal.confirm() await page.getByTestId('view-name').click() await expect(page.getByTestId('address-profile-button-eth')).toHaveText( accounts.getAddress('user', 5), ) + + await test.step('confirm name is unwrapped', async () => { + await expect(page.getByTestId('permissions-tab')).toBeVisible() + }) }) }) +test('should be able to detect an existing commit created on a private mempool for a legacy registration', async ({ + page, + login, + accounts, + time, + wallet, + consoleListener, + makePageObject, +}) => { + test.slow() + + const name = `registration-normal-${Date.now()}.eth` + const homePage = makePageObject('HomePage') + const registrationPage = makePageObject('RegistrationPage') + const transactionModal = makePageObject('TransactionModal') + await consoleListener.initialize({ + regex: /commit is:/, + }) + + await time.sync(500) + + await homePage.goto() + await login.connect() + + // should redirect to registration page + await homePage.searchInput.fill(name) + await homePage.searchInput.press('Enter') + await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() + + await registrationPage.primaryNameToggle.uncheck() + + // should go to profile editor step + await page.getByTestId('next-button').click() + + await test.step('should be able to find an existing commit', async () => { + await page.getByTestId('next-button').click() + + await transactionModal.closeButton.click() + + await expect(consoleListener.getMessages().length).toBeGreaterThan(0) + const commitHash = consoleListener.getMessages()[0].split(':')[1]?.trim() as Hash + + const approveTx = await walletClient.writeContract({ + abi: legacyEthRegistrarControllerCommitSnippet, + address: testClient.chain.contracts.legacyEthRegistrarController.address, + args: [commitHash!], + functionName: 'commit', + account: createAccounts().getAddress('user') as `0x${string}`, + }) + await waitForTransaction(approveTx) + + await page.route('https://api.findblock.xyz/**/*', async (route) => { + await route.fulfill({ + json: { + ok: true, + data: { + hash: approveTx, + }, + }, + }) + }) + + // should show countdown + await expect(page.getByTestId('countdown-circle')).toBeVisible() + await expect(page.getByTestId('countdown-circle')).toContainText(/^[0-6]?[0-9]$/) + await testClient.increaseTime({ seconds: 60 }) + + await expect(page.getByTestId('countdown-complete-check')).toBeVisible({ timeout: 10000 }) + }) + + await test.step('should be able to complete registration when modal is closed', async () => { + await expect(page.getByTestId('finish-button')).toBeEnabled() + + // should save the registration state and the transaction status + await page.reload() + await expect(page.getByTestId('finish-button')).toBeEnabled() + + // should allow finalising registration and automatically go to the complete step + await page.getByTestId('finish-button').click() + await expect(page.getByText('Open Wallet')).toBeVisible() + await transactionModal.confirmButton.click() + + await transactionModal.closeButton.click() + + await expect(transactionModal.transactionModal).toHaveCount(0) + + await wallet.authorize(Web3RequestKind.SendTransaction) + + await page.getByTestId('view-name').click() + await expect(page.getByTestId('address-profile-button-eth')).toHaveText( + accounts.getAddress('user', 5), + ) + + await test.step('confirm name is unwrapped', async () => { + await expect(page.getByTestId('permissions-tab')).not.toBeVisible() + }) + }) +}) test.describe('Error handling', () => { test('should be able to detect an existing commit created on a private mempool', async ({ page, diff --git a/package.json b/package.json index 8fb4c5e44..f326b4c8a 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@ensdomains/address-encoder": "1.1.1", "@ensdomains/content-hash": "^3.0.0-beta.5", "@ensdomains/ens-contracts": "1.2.0-beta.0", - "@ensdomains/ensjs": "4.0.2", + "@ensdomains/ensjs": "4.0.3-alpha.12", "@ensdomains/thorin": "0.6.50", "@metamask/post-message-stream": "^6.1.2", "@metamask/providers": "^14.0.2", @@ -280,4 +280,4 @@ } }, "packageManager": "pnpm@9.3.0" -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95a66f188..bb9c68a12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,8 +122,8 @@ importers: specifier: 1.2.0-beta.0 version: 1.2.0-beta.0 '@ensdomains/ensjs': - specifier: 4.0.2 - version: 4.0.2(encoding@0.1.13)(typescript@5.4.5)(viem@2.19.4(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8) + specifier: 4.0.3-alpha.12 + version: 4.0.3-alpha.12(encoding@0.1.13)(typescript@5.4.5)(viem@2.19.4(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8) '@ensdomains/thorin': specifier: 0.6.50 version: 0.6.50(react-dom@18.3.1(react@18.3.1))(react-transition-state@1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)) @@ -471,6 +471,48 @@ importers: specifier: ^1.0.0-pre.53 version: 1.0.0-pre.53 + .yalc/@ensdomains/ens-test-env: + dependencies: + '@ethersproject/wallet': + specifier: ^5.6.0 + version: 5.7.0 + ansi-colors: + specifier: ^4.1.1 + version: 4.1.3 + cli-progress: + specifier: ^3.10.0 + version: 3.12.0 + commander: + specifier: ^9.3.0 + version: 9.5.0 + concurrently: + specifier: ^7.1.0 + version: 7.6.0 + docker-compose: + specifier: ^0.24.7 + version: 0.24.8 + dotenv: + specifier: ^16.0.1 + version: 16.4.5 + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 + lz4: + specifier: ^0.6.5 + version: 0.6.5 + progress-stream: + specifier: ^2.0.0 + version: 2.0.0 + tar-fs: + specifier: ^2.1.1 + version: 2.1.1 + viem: + specifier: ^2.21.37 + version: 2.21.40(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8) + wait-on: + specifier: ^6.0.1 + version: 6.0.1 + packages: '@adobe/css-tools@4.3.3': @@ -1618,8 +1660,8 @@ packages: '@ensdomains/ensjs@2.1.0': resolution: {integrity: sha512-GRbGPT8Z/OJMDuxs75U/jUNEC0tbL0aj7/L/QQznGYKm/tiasp+ndLOaoULy9kKJFC0TBByqfFliEHDgoLhyog==} - '@ensdomains/ensjs@4.0.2': - resolution: {integrity: sha512-4vDIZEFAa1doNA3H9MppUHxflSDYYPVNyaDbdHLksTa4taq3y4dGpletX67Xea8nxI+cMfjEi4nOzLJmPzRE/g==} + '@ensdomains/ensjs@4.0.3-alpha.12': + resolution: {integrity: sha512-tJX+URJtSOtLgydE4V6uLOuG7hrMDah/ZOA2u/0hZuzhPwpVhS1jQRXU1aVnyT3klaLH5QqRL2J3SebzdxqGLQ==} peerDependencies: viem: ^2.9.2 @@ -11470,9 +11512,9 @@ snapshots: '@ensdomains/address-encoder@1.1.1': dependencies: - '@noble/curves': 1.4.0 - '@noble/hashes': 1.4.0 - '@scure/base': 1.1.6 + '@noble/curves': 1.6.0 + '@noble/hashes': 1.5.0 + '@scure/base': 1.1.9 '@ensdomains/buffer@0.1.1': {} @@ -11539,7 +11581,7 @@ snapshots: - bufferutil - utf-8-validate - '@ensdomains/ensjs@4.0.2(encoding@0.1.13)(typescript@5.4.5)(viem@2.19.4(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)': + '@ensdomains/ensjs@4.0.3-alpha.12(encoding@0.1.13)(typescript@5.4.5)(viem@2.19.4(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)': dependencies: '@adraffy/ens-normalize': 1.10.1 '@ensdomains/address-encoder': 1.1.1 @@ -12408,8 +12450,8 @@ snapshots: '@metamask/utils@8.4.0': dependencies: '@ethereumjs/tx': 4.2.0 - '@noble/hashes': 1.4.0 - '@scure/base': 1.1.6 + '@noble/hashes': 1.5.0 + '@scure/base': 1.1.9 '@types/debug': 4.1.12 debug: 4.3.4(supports-color@8.1.1) pony-cause: 2.1.11 @@ -12423,7 +12465,7 @@ snapshots: dependencies: '@ethereumjs/tx': 4.2.0 '@metamask/superstruct': 3.1.0 - '@noble/hashes': 1.4.0 + '@noble/hashes': 1.5.0 '@scure/base': 1.1.6 '@types/debug': 4.1.12 debug: 4.3.6(supports-color@5.5.0) @@ -13175,19 +13217,19 @@ snapshots: dependencies: '@noble/hashes': 1.2.0 '@noble/secp256k1': 1.7.1 - '@scure/base': 1.1.6 + '@scure/base': 1.1.9 '@scure/bip32@1.3.3': dependencies: '@noble/curves': 1.3.0 '@noble/hashes': 1.3.3 - '@scure/base': 1.1.6 + '@scure/base': 1.1.9 '@scure/bip32@1.4.0': dependencies: '@noble/curves': 1.4.0 '@noble/hashes': 1.4.0 - '@scure/base': 1.1.6 + '@scure/base': 1.1.9 '@scure/bip32@1.5.0': dependencies: @@ -13198,17 +13240,17 @@ snapshots: '@scure/bip39@1.1.1': dependencies: '@noble/hashes': 1.2.0 - '@scure/base': 1.1.6 + '@scure/base': 1.1.9 '@scure/bip39@1.2.2': dependencies: '@noble/hashes': 1.3.3 - '@scure/base': 1.1.6 + '@scure/base': 1.1.9 '@scure/bip39@1.3.0': dependencies: '@noble/hashes': 1.4.0 - '@scure/base': 1.1.6 + '@scure/base': 1.1.9 '@scure/bip39@1.4.0': dependencies: @@ -15273,7 +15315,7 @@ snapshots: capnp-ts@0.7.0: dependencies: debug: 4.3.6(supports-color@5.5.0) - tslib: 2.6.2 + tslib: 2.6.3 transitivePeerDependencies: - supports-color @@ -16527,7 +16569,7 @@ snapshots: ethereum-bloom-filters@1.1.0: dependencies: - '@noble/hashes': 1.4.0 + '@noble/hashes': 1.5.0 ethereum-cryptography@0.1.3: dependencies: @@ -18223,7 +18265,7 @@ snapshots: media-query-parser@2.0.2: dependencies: - '@babel/runtime': 7.24.6 + '@babel/runtime': 7.25.0 media-typer@0.3.0: {} @@ -21555,8 +21597,8 @@ snapshots: webauthn-p256@0.0.5: dependencies: - '@noble/curves': 1.4.0 - '@noble/hashes': 1.4.0 + '@noble/curves': 1.6.0 + '@noble/hashes': 1.5.0 webextension-polyfill@0.10.0: {} diff --git a/src/components/pages/profile/[name]/registration/steps/Complete.tsx b/src/components/pages/profile/[name]/registration/steps/Complete.tsx index bf647e8d7..0978a22c7 100644 --- a/src/components/pages/profile/[name]/registration/steps/Complete.tsx +++ b/src/components/pages/profile/[name]/registration/steps/Complete.tsx @@ -3,7 +3,6 @@ import { Fragment, useEffect, useMemo, useState } from 'react' import type ConfettiT from 'react-confetti' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' -import { decodeEventLog } from 'viem' import { useAccount } from 'wagmi' import { tokenise } from '@ensdomains/ensjs/utils' @@ -12,6 +11,7 @@ import { Button, mq, Typography } from '@ensdomains/thorin' import MobileFullWidth from '@app/components/@atoms/MobileFullWidth' import NFTTemplate from '@app/components/@molecules/NFTTemplate/NFTTemplate' import { Card } from '@app/components/Card' +import { useRegistrationValueFromRegisterReceipt } from '@app/hooks/pages/register/useRegistrationValueFromRegisterReceipt' import useWindowSize from '@app/hooks/useWindowSize' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' import { dateFromDateDiff } from '@app/utils/date' @@ -125,47 +125,6 @@ const SubtitleWithGradient = styled(Typography)( `, ) -const nameRegisteredSnippet = [ - { - anonymous: false, - inputs: [ - { - indexed: false, - name: 'name', - type: 'string', - }, - { - indexed: true, - name: 'label', - type: 'bytes32', - }, - { - indexed: true, - internalType: 'address', - name: 'owner', - type: 'address', - }, - { - indexed: false, - name: 'baseCost', - type: 'uint256', - }, - { - indexed: false, - name: 'premium', - type: 'uint256', - }, - { - indexed: false, - name: 'expires', - type: 'uint256', - }, - ], - name: 'NameRegistered', - type: 'event', - }, -] as const - const Confetti = dynamic(() => import('react-confetti').then((mod) => mod.default as typeof ConfettiT), ) @@ -190,26 +149,10 @@ const useEthInvoice = ( const commitReceipt = commitTxFlow?.minedData const registerReceipt = registerTxFlow?.minedData - const registrationValue = useMemo(() => { - if (!registerReceipt) return null - for (const log of registerReceipt.logs) { - try { - const { - args: { baseCost, premium }, - } = decodeEventLog({ - abi: nameRegisteredSnippet, - topics: log.topics, - data: log.data, - eventName: 'NameRegistered', - }) - return baseCost + premium - // eslint-disable-next-line no-empty - } catch {} - } - return null - }, [registerReceipt]) + const { data: registrationValue, isLoading: isRegistrationValueLoading } = + useRegistrationValueFromRegisterReceipt({ registerReceipt }) - const isLoading = !commitReceipt || !registerReceipt + const isLoading = !commitReceipt || !registerReceipt || isRegistrationValueLoading useEffect(() => { const storage = localStorage.getItem(`avatar-src-${name}`) diff --git a/src/components/pages/profile/[name]/registration/steps/Transactions.tsx b/src/components/pages/profile/[name]/registration/steps/Transactions.tsx index a02e00894..40fed49e8 100644 --- a/src/components/pages/profile/[name]/registration/steps/Transactions.tsx +++ b/src/components/pages/profile/[name]/registration/steps/Transactions.tsx @@ -4,7 +4,7 @@ import styled, { css } from 'styled-components' import { match, P } from 'ts-pattern' import { useAccount } from 'wagmi' -import { makeCommitment } from '@ensdomains/ensjs/utils' +import { makeCommitment, makeLegacyCommitment } from '@ensdomains/ensjs/utils' import { Button, CountdownCircle, Dialog, Heading, mq, Spinner } from '@ensdomains/thorin' import MobileFullWidth from '@app/components/@atoms/MobileFullWidth' @@ -19,6 +19,8 @@ import useRegistrationParams from '@app/hooks/useRegistrationParams' import { CenteredTypography } from '@app/transaction-flow/input/ProfileEditor/components/CenteredTypography' import { createTransactionItem } from '@app/transaction-flow/transaction' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { isLegacyRegistration } from '@app/utils/registration/isLegacyRegistration' +import { makeLegacyRegistrationParams } from '@app/utils/registration/makeLegacyRegistrationParams' import { ONE_DAY } from '@app/utils/time' import { RegistrationReducerDataItem } from '../types' @@ -241,8 +243,13 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { const commitCouldBeFound = !commitTx?.stage || commitTx.stage === 'confirm' || commitTx.stage === 'failed' + const isLegacyCommit = isLegacyRegistration(registrationParams) useExistingCommitment({ - commitment: makeCommitment(registrationParams), + registrationParams, + commitment: isLegacyCommit + ? makeLegacyCommitment(makeLegacyRegistrationParams(registrationParams)) + : makeCommitment(registrationParams), + isLegacyCommit, enabled: commitCouldBeFound, commitKey, }) diff --git a/src/hooks/chain/useEstimateGasWithStateOverride.ts b/src/hooks/chain/useEstimateGasWithStateOverride.ts index 34c23b8e4..4d4da63f7 100644 --- a/src/hooks/chain/useEstimateGasWithStateOverride.ts +++ b/src/hooks/chain/useEstimateGasWithStateOverride.ts @@ -76,14 +76,14 @@ type StateOverride = { } } -type TransactionItem = { +export type TransactionItem = { [TName in TransactionName]: Omit, 'client' | 'connectorClient'> & { name: TName stateOverride?: UserStateOverrides } }[TransactionName] -type UseEstimateGasWithStateOverrideParameters< +export type UseEstimateGasWithStateOverrideParameters< TransactionItems extends TransactionItem[] | readonly TransactionItem[], > = { transactions: TransactionItems @@ -295,9 +295,10 @@ export const useEstimateGasWithStateOverride = < const data = useMemo(() => { if (!gasPrice || !query.data) { + const transactions = params.transactions ?? [] return { gasEstimate: 0n, - gasEstimateArray: params.transactions.map(() => 0n) as GasEstimateArray, + gasEstimateArray: transactions.map(() => 0n) as GasEstimateArray, gasCost: 0n, gasCostEth: '0', } diff --git a/src/hooks/gasEstimation/calculateTransactions.ts b/src/hooks/gasEstimation/calculateTransactions.ts new file mode 100644 index 000000000..c7a259fb9 --- /dev/null +++ b/src/hooks/gasEstimation/calculateTransactions.ts @@ -0,0 +1,80 @@ +import { match } from 'ts-pattern' +import { Address, parseEther } from 'viem' + +import { + makeCommitment, + makeLegacyCommitment, + RegistrationParameters, +} from '@ensdomains/ensjs/utils' + +import { isLegacyRegistration } from '@app/utils/registration/isLegacyRegistration' +import { makeLegacyRegistrationParams } from '@app/utils/registration/makeLegacyRegistrationParams' + +import { useEstimateGasWithStateOverride } from '../chain/useEstimateGasWithStateOverride' + +type ReturnType = null | Parameters[0]['transactions'] + +export const calculateTransactions = ({ + registrationParams, + ethRegistrarControllerAddress, + legacyEthRegistrarControllerAddress, + fiveMinutesAgoInSeconds, + price, +}: { + registrationParams?: RegistrationParameters + ethRegistrarControllerAddress: unknown + legacyEthRegistrarControllerAddress: unknown + fiveMinutesAgoInSeconds: number + price?: { base: bigint; premium: bigint } +}): ReturnType => { + if ( + !registrationParams || + !ethRegistrarControllerAddress || + !legacyEthRegistrarControllerAddress || + !price + ) + return null + + const isLegacy = isLegacyRegistration(registrationParams) + + const registrationStateOverride = match(isLegacy) + .with(true, () => ({ + address: legacyEthRegistrarControllerAddress as Address, + stateDiff: [ + { + slot: 5, + keys: [makeLegacyCommitment(makeLegacyRegistrationParams(registrationParams))], + value: BigInt(fiveMinutesAgoInSeconds), + }, + ], + })) + .with(false, () => ({ + address: ethRegistrarControllerAddress as Address, + stateDiff: [ + { + slot: 1, + keys: [makeCommitment(registrationParams)], + value: BigInt(fiveMinutesAgoInSeconds), + }, + ], + })) + .exhaustive() + + return [ + { + name: 'commitName', + data: registrationParams, + }, + { + name: 'registerName', + data: registrationParams, + stateOverride: [ + registrationStateOverride, + { + address: registrationParams.owner, + balance: price ? (price.base + price.premium) * 2n + parseEther('10000') : undefined, + }, + ], + }, + ] as const +} diff --git a/src/hooks/gasEstimation/useEstimateRegistration.ts b/src/hooks/gasEstimation/useEstimateRegistration.ts index 3d969e678..d03eea2f7 100644 --- a/src/hooks/gasEstimation/useEstimateRegistration.ts +++ b/src/hooks/gasEstimation/useEstimateRegistration.ts @@ -1,7 +1,4 @@ import { useMemo } from 'react' -import { parseEther } from 'viem' - -import { makeCommitment } from '@ensdomains/ensjs/utils' import { RegistrationReducerDataItem } from '@app/components/pages/profile/[name]/registration/types' import { deriveYearlyFee } from '@app/utils/utils' @@ -13,6 +10,7 @@ import { useEstimateGasWithStateOverride } from '../chain/useEstimateGasWithStat import { useGasPrice } from '../chain/useGasPrice' import { usePrice } from '../ensjs/public/usePrice' import useRegistrationParams from '../useRegistrationParams' +import { calculateTransactions } from './calculateTransactions' type UseEstimateFullRegistrationParameters = { registrationData: RegistrationReducerDataItem @@ -38,7 +36,9 @@ export const useEstimateFullRegistration = ({ contract: 'ensEthRegistrarController', }) - const commitment = useMemo(() => makeCommitment(registrationParams), [registrationParams]) + const legacyEthRegistrarControllerAddress = useContractAddress({ + contract: 'legacyEthRegistrarController', + }) const { data: blockTimestamp } = useBlockTimestamp() // default to use block timestamp as reference @@ -54,34 +54,16 @@ export const useEstimateFullRegistration = ({ [timestampReference], ) + const transactions = calculateTransactions({ + registrationParams, + ethRegistrarControllerAddress, + legacyEthRegistrarControllerAddress, + fiveMinutesAgoInSeconds, + price, + }) const { data, isLoading } = useEstimateGasWithStateOverride({ - transactions: [ - { - name: 'commitName', - data: registrationParams, - }, - { - name: 'registerName', - data: registrationParams, - stateOverride: [ - { - address: ethRegistrarControllerAddress, - stateDiff: [ - { - slot: 1, - keys: [commitment], - value: BigInt(fiveMinutesAgoInSeconds), - }, - ], - }, - { - address: registrationParams.owner, - balance: price ? (price.base + price.premium) * 2n + parseEther('10000') : undefined, - }, - ], - }, - ], - enabled: !!ethRegistrarControllerAddress && !!price, + transactions: transactions!, + enabled: !!transactions, }) const premiumFee = price?.premium diff --git a/src/hooks/pages/register/useRegistrationValueFromRegisterReceipt.ts b/src/hooks/pages/register/useRegistrationValueFromRegisterReceipt.ts new file mode 100644 index 000000000..f0001137f --- /dev/null +++ b/src/hooks/pages/register/useRegistrationValueFromRegisterReceipt.ts @@ -0,0 +1,65 @@ +import { decodeEventLog } from 'viem' + +import { + ethRegistrarControllerNameRegisteredEventSnippet, + legacyEthRegistrarControllerNameRegisteredEventSnippet, +} from '@ensdomains/ensjs/contracts' + +import { MinedData } from '@app/types' +import { useQuery } from '@app/utils/query/useQuery' + +const decodeLegacyNameRegisteredEventLog = (log: MinedData['logs'][number]): Promise => + new Promise((resolve, reject) => { + try { + const t = decodeEventLog({ + abi: legacyEthRegistrarControllerNameRegisteredEventSnippet, + topics: log.topics, + data: log.data, + eventName: 'NameRegistered', + }) + if (!t.args.cost) reject() + resolve(t.args.cost) + } catch { + reject() + } + }) + +const decodeWrappedNameRegisteredEventLog = (log: MinedData['logs'][number]): Promise => + new Promise((resolve, reject) => { + try { + const t = decodeEventLog({ + abi: ethRegistrarControllerNameRegisteredEventSnippet, + topics: log.topics, + data: log.data, + eventName: 'NameRegistered', + }) + resolve(t.args.baseCost + t.args.premium) + } catch { + reject() + } + }) + +export const useRegistrationValueFromRegisterReceipt = ({ + registerReceipt, +}: { + registerReceipt?: MinedData +}) => { + return useQuery({ + queryKey: ['registration-value', registerReceipt], + queryFn: async () => { + try { + const promises = registerReceipt!.logs + .map((log) => [ + decodeLegacyNameRegisteredEventLog(log), + decodeWrappedNameRegisteredEventLog(log), + ]) + .flat() + return await Promise.any(promises) + } catch { + return null + } + }, + enabled: !!registerReceipt, + retry: false, + }) +} diff --git a/src/hooks/registration/useExistingCommitment.ts b/src/hooks/registration/useExistingCommitment.ts index 5af4e5f8a..78a33ea9f 100644 --- a/src/hooks/registration/useExistingCommitment.ts +++ b/src/hooks/registration/useExistingCommitment.ts @@ -13,6 +13,8 @@ import { ethRegistrarControllerCommitmentsSnippet, ethRegistrarControllerCommitSnippet, getChainContractAddress, + legacyEthRegistrarControllerCommitmentsSnippet, + legacyEthRegistrarControllerCommitSnippet, } from '@ensdomains/ensjs/contracts' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' @@ -29,6 +31,7 @@ import { getBlockMetadataByTimestamp } from './utils/getBlockMetadataByTimestamp type UseExistingCommitmentParameters = { commitment?: Hex commitKey?: string + isLegacyCommit?: boolean } type UseExistingCommitmentInternalParameters = { @@ -121,7 +124,7 @@ const execTransactionSnippet = [ }, ] as const -const getExistingCommitmentQueryFn = +const getExistingWrappedCommitmentQueryFn = (config: ConfigWithEns) => ({ addRecentTransaction, @@ -244,6 +247,162 @@ const getExistingCommitmentQueryFn = } as const } +const getExistingLegacyCommitmentQueryFn = + (config: ConfigWithEns) => + ({ + addRecentTransaction, + setTransactionHashFromUpdate, + isSafeTx, + }: UseExistingCommitmentInternalParameters) => + async ({ + queryKey: [{ commitment, commitKey }, chainId, address], + }: QueryFunctionContext>): Promise => { + if (!commitment) throw new Error('commitment is required') + if (!commitKey) throw new Error('commitKey is required') + if (!address) throw new Error('address is required') + + const client = config.getClient({ chainId }) + const legacyEthRegistrarControllerAddress = getChainContractAddress({ + client, + contract: 'legacyEthRegistrarController', + }) + const multicall3Address = getChainContractAddress({ + client, + contract: 'multicall3', + }) + + const [commitmentTimestamp, maxCommitmentAge, blockTimestamp] = await Promise.all([ + readContract(client, { + abi: legacyEthRegistrarControllerCommitmentsSnippet, + address: legacyEthRegistrarControllerAddress, + functionName: 'commitments', + args: [commitment], + }), + readContract(client, { + abi: maxCommitmentAgeSnippet, + address: legacyEthRegistrarControllerAddress, + functionName: 'maxCommitmentAge', + }), + readContract(client, { + abi: getCurrentBlockTimestampSnippet, + address: multicall3Address, + functionName: 'getCurrentBlockTimestamp', + }), + ]) + if (!commitmentTimestamp || commitmentTimestamp === 0n) return null + + const commitmentAge = blockTimestamp - commitmentTimestamp + const commitmentTimestampNumber = Number(commitmentTimestamp) + + const existsFailure = () => + ({ status: 'commitmentExists', timestamp: commitmentTimestampNumber }) as const + + if (commitmentAge > maxCommitmentAge) + return { status: 'commitmentExpired', timestamp: commitmentTimestampNumber } as const + + const blockMetadata = await getBlockMetadataByTimestamp(client, { + timestamp: commitmentTimestamp, + }) + if (!blockMetadata.ok) return existsFailure() + + const blockData = await getBlock(client, { + blockHash: blockMetadata.data.hash, + includeTransactions: true, + }).catch(() => null) + if (!blockData) return existsFailure() + + const inputData = encodeFunctionData({ + abi: legacyEthRegistrarControllerCommitSnippet, + args: [commitment], + functionName: 'commit', + }) + + const transaction = (() => { + const checksummedAddress = getAddress(address) + const checksummedEthRegistrarControllerAddress = getAddress( + legacyEthRegistrarControllerAddress, + ) + if (isSafeTx) { + const execTransactionFunctionSelector = toFunctionSelector(execTransactionSnippet[0]) + const foundTransaction = blockData.transactions.find((t) => { + // safe transaction gets sent to the safe contract itself + if (!t.to || getAddress(t.to) !== checksummedAddress) return false + if (!t.input.startsWith(execTransactionFunctionSelector)) return false + const { args: safeTxData } = decodeFunctionData({ + abi: execTransactionSnippet, + data: t.input, + }) + if (getAddress(safeTxData[0]) !== checksummedEthRegistrarControllerAddress) return false + if (getAddress(safeTxData[2]) !== inputData) return false + return true + }) + return foundTransaction + } + const foundTransaction = blockData.transactions.find((t) => { + if (getAddress(t.from) !== checksummedAddress) return false + if (!t.to || getAddress(t.to) !== checksummedEthRegistrarControllerAddress) return false + if (t.input !== inputData) return false + return true + }) + return foundTransaction + })() + + if (!transaction) return existsFailure() + + const transactionReceipt = await getTransactionReceipt(client, { + hash: transaction.hash, + }) + + if (transactionReceipt.status !== 'success') return existsFailure() + + setTransactionHashFromUpdate(commitKey, transaction.hash) + addRecentTransaction({ + ...transaction, + hash: transaction.hash, + action: 'commitName', + key: commitKey, + input: inputData, + timestamp: commitmentTimestampNumber, + isSafeTx, + searchRetries: 0, + }) + + return { + status: 'transactionExists', + timestamp: commitmentTimestampNumber, + } as const + } + +const getExistingCommitmentQueryFn = + (config: ConfigWithEns) => + ({ + addRecentTransaction, + setTransactionHashFromUpdate, + isSafeTx, + }: UseExistingCommitmentInternalParameters) => + async ( + context: QueryFunctionContext>, + ): Promise => { + const { + queryKey: [{ commitment, commitKey, isLegacyCommit }, , address], + } = context + if (!commitment) throw new Error('commitment is required') + if (!commitKey) throw new Error('commitKey is required') + if (!address) throw new Error('address is required') + + if (isLegacyCommit) + return getExistingLegacyCommitmentQueryFn(config)({ + addRecentTransaction, + setTransactionHashFromUpdate, + isSafeTx, + })(context) + return getExistingWrappedCommitmentQueryFn(config)({ + addRecentTransaction, + setTransactionHashFromUpdate, + isSafeTx, + })(context) + } + export const useExistingCommitment = ({ // config enabled = true, diff --git a/src/hooks/registration/useSimulateRegistration.ts b/src/hooks/registration/useSimulateRegistration.ts index 962e311ea..e133b095c 100644 --- a/src/hooks/registration/useSimulateRegistration.ts +++ b/src/hooks/registration/useSimulateRegistration.ts @@ -1,8 +1,18 @@ +import { Address } from 'viem' import { usePublicClient, useSimulateContract, UseSimulateContractParameters } from 'wagmi' -import { ethRegistrarControllerRegisterSnippet } from '@ensdomains/ensjs/contracts' -import { makeRegistrationTuple, RegistrationParameters } from '@ensdomains/ensjs/utils' +import { + ethRegistrarControllerRegisterSnippet, + legacyEthRegistrarControllerRegisterWithConfigSnippet, +} from '@ensdomains/ensjs/contracts' +import { + makeLegacyRegistrationWithConfigTuple, + makeRegistrationTuple, + RegistrationParameters, +} from '@ensdomains/ensjs/utils' +import { isLegacyRegistration } from '@app/utils/registration/isLegacyRegistration' +import { makeLegacyRegistrationParams } from '@app/utils/registration/makeLegacyRegistrationParams' import { calculateValueWithBuffer } from '@app/utils/utils' import { usePrice } from '../ensjs/public/usePrice' @@ -11,6 +21,46 @@ type UseSimulateRegistrationParameters = Pick + +type UseSimulateLegacyEthRegistrarControllerRegisterReturnType = UseSimulateContractParameters< + typeof legacyEthRegistrarControllerRegisterWithConfigSnippet, + 'registerWithConfig' +> + +type MakeSimulateRegistrationParamsReturnType = + | UseSimulateEthRegistrarControllerRegisterReturnType + | UseSimulateLegacyEthRegistrarControllerRegisterReturnType + +export const makeSimulateRegistrationParams = ({ + registrationParams, + ensEthRegistrarControllerAddress, + legacyEthRegistrarControllerAddress, +}: { + registrationParams: RegistrationParameters + ensEthRegistrarControllerAddress: Address + legacyEthRegistrarControllerAddress: Address +}): MakeSimulateRegistrationParamsReturnType => { + if (isLegacyRegistration(registrationParams)) { + return { + abi: legacyEthRegistrarControllerRegisterWithConfigSnippet, + address: legacyEthRegistrarControllerAddress, + functionName: 'registerWithConfig', + args: makeLegacyRegistrationWithConfigTuple(makeLegacyRegistrationParams(registrationParams)), + } + } + + return { + abi: ethRegistrarControllerRegisterSnippet, + address: ensEthRegistrarControllerAddress, + functionName: 'register', + args: makeRegistrationTuple(registrationParams), + } +} + export const useSimulateRegistration = ({ registrationParams, query, @@ -27,10 +77,12 @@ export const useSimulateRegistration = ({ const value = base + premium return useSimulateContract({ - abi: ethRegistrarControllerRegisterSnippet, - address: client.chain.contracts.ensEthRegistrarController.address, - functionName: 'register', - args: makeRegistrationTuple(registrationParams), + ...makeSimulateRegistrationParams({ + registrationParams, + ensEthRegistrarControllerAddress: client.chain.contracts.ensEthRegistrarController.address, + legacyEthRegistrarControllerAddress: + client.chain.contracts.legacyEthRegistrarController.address, + }), value: calculateValueWithBuffer(value), query, }) diff --git a/src/hooks/registration/utils/useExistingCommitment.test.ts b/src/hooks/registration/utils/useExistingCommitment.test.ts new file mode 100644 index 000000000..22db8ffad --- /dev/null +++ b/src/hooks/registration/utils/useExistingCommitment.test.ts @@ -0,0 +1,394 @@ +/* eslint-disable no-promise-executor-return */ +import { mockFunction, renderHook, waitFor } from '@app/test-utils' + +import { getBlock, getTransactionReceipt, readContract } from 'viem/actions' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccount, useBlockNumber, useChainId, useConfig, usePublicClient } from 'wagmi' + +import { useChainName } from '@app/hooks/chain/useChainName' +import { useInvalidateOnBlock } from '@app/hooks/chain/useInvalidateOnBlock' +import { useAddRecentTransaction } from '@app/hooks/transactions/useAddRecentTransaction' +import { useIsSafeApp } from '@app/hooks/useIsSafeApp' +import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' + +import { useExistingCommitment } from '../useExistingCommitment' +import { getBlockMetadataByTimestamp } from './getBlockMetadataByTimestamp' + +vi.mock('@app/hooks/chain/useChainName') +vi.mock('@app/hooks/transactions/useAddRecentTransaction') +vi.mock('@app/transaction-flow/TransactionFlowProvider') +vi.mock('@app/hooks/chain/useInvalidateOnBlock') +vi.mock('@app/hooks/useIsSafeApp') +vi.mock('wagmi') +vi.mock('viem/actions') +vi.mock('../utils/getBlockMetadataByTimestamp') +vi.mock('@ensdomains/ensjs/contracts', () => ({ + ethRegistrarControllerCommitSnippet: [ + { + inputs: [{ name: 'commitment', type: 'bytes32' }], + name: 'commit', + outputs: [], + type: 'function', + }, + ], + ethRegistrarControllerCommitmentsSnippet: [ + { + inputs: [{ name: 'commitment', type: 'bytes32' }], + name: 'commitments', + outputs: [{ name: '', type: 'uint256' }], + type: 'function', + }, + ], + legacyEthRegistrarControllerCommitSnippet: [ + { + inputs: [{ name: 'commitment', type: 'bytes32' }], + name: 'commit', + outputs: [], + type: 'function', + }, + ], + legacyEthRegistrarControllerCommitmentsSnippet: [ + { + inputs: [{ name: 'commitment', type: 'bytes32' }], + name: 'commitments', + outputs: [{ name: '', type: 'uint256' }], + type: 'function', + }, + ], + ethRegistrarControllerErrors: [], + ethRegistrarControllerABI: [], + ethRegistrarControllerInterface: {}, + legacyEthRegistrarControllerABI: [], + legacyEthRegistrarControllerInterface: {}, + nameWrapperErrors: [], + nameWrapperABI: [], + nameWrapperInterface: {}, + nameWrapperCommitSnippet: [ + { + inputs: [{ name: 'commitment', type: 'bytes32' }], + name: 'commit', + outputs: [], + type: 'function', + }, + ], + nameWrapperCommitmentsSnippet: [ + { + inputs: [{ name: 'commitment', type: 'bytes32' }], + name: 'commitments', + outputs: [{ name: '', type: 'uint256' }], + type: 'function', + }, + ], + getChainContractAddress: ({ client, contract }: { client: any; contract: string }) => + client.chain.contracts[contract].address, + __esModule: true, + default: {}, +})) + +const mockUseChainName = mockFunction(useChainName) +const mockUseAddRecentTransaction = mockFunction(useAddRecentTransaction) +const mockUseTransactionFlow = mockFunction(useTransactionFlow) +const mockUsePublicClient = mockFunction(usePublicClient) +const mockUseAccount = mockFunction(useAccount) +const mockUseConfig = mockFunction(useConfig) +const mockUseChainId = mockFunction(useChainId) +const mockUseBlockNumber = mockFunction(useBlockNumber) +const mockUseInvalidateOnBlock = mockFunction(useInvalidateOnBlock) +const mockUseIsSafeApp = mockFunction(useIsSafeApp) +const mockReadContract = mockFunction(readContract) +const mockGetBlock = mockFunction(getBlock) +const mockGetTransactionReceipt = mockFunction(getTransactionReceipt) +const mockGetBlockMetadataByTimestamp = mockFunction(getBlockMetadataByTimestamp) + +describe('useExistingCommitment', () => { + const mockCommitment = '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + const mockCommitKey = 'commit-test-0xaddress' + const mockAddress = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' + const mockTime = 1000n + const mockMaxAge = 300n // 5 minutes in seconds + + const mockClient = { + request: vi.fn(), + chain: { + contracts: { + ensEthRegistrarController: { address: '0xcontroller' }, + legacyEthRegistrarController: { address: '0xlegacycontroller' }, + multicall3: { address: '0xmulticall' }, + }, + }, + } + + beforeEach(() => { + vi.clearAllMocks() + mockUseChainName.mockReturnValue('mainnet') + mockUseAddRecentTransaction.mockReturnValue(vi.fn()) + mockUseTransactionFlow.mockReturnValue({ + setTransactionHashFromUpdate: vi.fn(), + }) + mockUsePublicClient.mockReturnValue(mockClient) + mockUseAccount.mockReturnValue({ address: mockAddress }) + mockUseChainId.mockReturnValue(1) + mockUseConfig.mockReturnValue({ + blockExplorers: { + default: { + name: 'Etherscan', + url: 'https://etherscan.io', + apiUrl: 'https://api.etherscan.io/api', + }, + }, + getClient: () => mockClient, + _isEns: true, + }) + mockUseBlockNumber.mockReturnValue({ data: 1234n }) + mockUseInvalidateOnBlock.mockReturnValue({ data: undefined }) + mockUseIsSafeApp.mockReturnValue({ data: false, isLoading: false }) + }) + + describe('No Existing Commitment', () => { + it('should return null when no commitment exists', async () => { + mockReadContract.mockResolvedValueOnce(0n) // commitment timestamp + + const { result } = renderHook(() => + useExistingCommitment({ + commitment: mockCommitment, + commitKey: mockCommitKey, + isLegacyCommit: false, + scopeKey: mockAddress, + }), + ) + + await waitFor(() => { + expect(result.current.data).toBeNull() + }) + }) + }) + + describe('Valid Commitment', () => { + it('should return commitmentExists for valid recent commitment', async () => { + mockReadContract + .mockResolvedValueOnce(mockTime) // commitment timestamp + .mockResolvedValueOnce(mockMaxAge) // max age + .mockResolvedValueOnce(mockTime + 30n) // current block timestamp (30s after commitment) + + mockGetBlockMetadataByTimestamp.mockResolvedValueOnce({ + ok: false, + }) + + const { result } = renderHook(() => + useExistingCommitment({ + commitment: mockCommitment, + commitKey: mockCommitKey, + isLegacyCommit: false, + scopeKey: mockAddress, + }), + ) + + await waitFor(() => { + expect(result.current.data).toEqual({ + status: 'commitmentExists', + timestamp: Number(mockTime), + }) + }) + }) + + it('should verify commitment', async () => { + mockReadContract + .mockResolvedValueOnce(mockTime) // commitment timestamp + .mockResolvedValueOnce(mockMaxAge) // max age + .mockResolvedValueOnce(mockTime + 30n) // current block timestamp (30s after commitment) + + mockGetBlockMetadataByTimestamp.mockResolvedValueOnce({ + ok: false, + }) + + const { result } = renderHook(() => + useExistingCommitment({ + commitment: mockCommitment, + commitKey: mockCommitKey, + isLegacyCommit: false, + scopeKey: mockAddress, + }), + ) + + await waitFor(() => { + expect(result.current.data).toEqual({ + status: 'commitmentExists', + timestamp: Number(mockTime), + }) + }) + + // Verify it used the correct contract address + expect(mockReadContract).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + address: '0xcontroller', + }), + ) + + // Verify it called the correct contract functions + expect(mockReadContract).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + functionName: 'commitments', + args: [mockCommitment], + }), + ) + + expect(mockReadContract).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + functionName: 'maxCommitmentAge', + }), + ) + + expect(mockReadContract).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + functionName: 'getCurrentBlockTimestamp', + }), + ) + }) + }) + + describe('Expired Commitment', () => { + it('should return commitmentExpired for old commitment', async () => { + mockReadContract + .mockResolvedValueOnce(mockTime) // commitment timestamp + .mockResolvedValueOnce(mockMaxAge) // max age + .mockResolvedValueOnce(mockTime + mockMaxAge + 1n) // current block timestamp (past max age) + + const { result } = renderHook(() => + useExistingCommitment({ + commitment: mockCommitment, + commitKey: mockCommitKey, + isLegacyCommit: false, + scopeKey: mockAddress, + }), + ) + + await waitFor(() => { + expect(result.current.data).toEqual({ + status: 'commitmentExpired', + timestamp: Number(mockTime), + }) + }) + }) + }) + + describe('Legacy Commitment', () => { + it('should handle legacy commitment check', async () => { + mockReadContract + .mockResolvedValueOnce(mockTime) // commitment timestamp + .mockResolvedValueOnce(mockMaxAge) // max age + .mockResolvedValueOnce(mockTime + 30n) // current block timestamp + + mockGetBlockMetadataByTimestamp.mockResolvedValueOnce({ + ok: false, + }) + + const { result } = renderHook(() => + useExistingCommitment({ + commitment: mockCommitment, + commitKey: mockCommitKey, + isLegacyCommit: true, + scopeKey: mockAddress, + }), + ) + + await waitFor(() => { + expect(result.current.data).toEqual({ + status: 'commitmentExists', + timestamp: Number(mockTime), + }) + }) + + // Verify it used legacy controller address + expect(mockReadContract).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + address: '0xlegacycontroller', + }), + ) + }) + }) + + describe('Wrapped Commitment', () => { + it('should verify wrapped commitment functionality', async () => { + mockReadContract + .mockResolvedValueOnce(mockTime) // commitment timestamp + .mockResolvedValueOnce(mockMaxAge) // max age + .mockResolvedValueOnce(mockTime + 30n) // current block timestamp (30s after commitment) + + mockGetBlockMetadataByTimestamp.mockResolvedValueOnce({ + ok: false, + }) + + const { result } = renderHook(() => + useExistingCommitment({ + commitment: mockCommitment, + commitKey: mockCommitKey, + isLegacyCommit: false, + scopeKey: mockAddress, + isWrappedCommitment: true, + }), + ) + + await waitFor(() => { + expect(result.current.data).toEqual({ + status: 'commitmentExists', + timestamp: Number(mockTime), + }) + }) + + // Verify it used the correct contract address + expect(mockReadContract).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + address: '0xcontroller', + }), + ) + + // Verify it called the correct contract functions + expect(mockReadContract).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + functionName: 'commitments', + args: [mockCommitment], + }), + ) + + expect(mockReadContract).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + functionName: 'maxCommitmentAge', + }), + ) + + expect(mockReadContract).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + functionName: 'getCurrentBlockTimestamp', + }), + ) + }) + }) + + describe('Error Handling', () => { + it('should handle contract read errors', async () => { + mockReadContract.mockRejectedValueOnce(new Error('Contract error')) + + const { result } = renderHook(() => + useExistingCommitment({ + commitment: mockCommitment, + commitKey: mockCommitKey, + isLegacyCommit: false, + scopeKey: mockAddress, + }), + ) + + await waitFor(() => { + expect(result.current.error).toBeTruthy() + }) + }) + }) +}) diff --git a/src/transaction-flow/transaction/commitName.ts b/src/transaction-flow/transaction/commitName.ts index b3cb4167a..0b568b4e4 100644 --- a/src/transaction-flow/transaction/commitName.ts +++ b/src/transaction-flow/transaction/commitName.ts @@ -1,9 +1,11 @@ import type { TFunction } from 'react-i18next' import { RegistrationParameters } from '@ensdomains/ensjs/utils' -import { commitName } from '@ensdomains/ensjs/wallet' +import { commitName, legacyCommitName } from '@ensdomains/ensjs/wallet' import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' +import { isLegacyRegistration } from '@app/utils/registration/isLegacyRegistration' +import { makeLegacyRegistrationParams } from '@app/utils/registration/makeLegacyRegistrationParams' type Data = RegistrationParameters & { name: string } @@ -27,6 +29,8 @@ const displayItems = ( ] const transaction = async ({ connectorClient, data }: TransactionFunctionParameters) => { + if (isLegacyRegistration(data)) + return legacyCommitName.makeFunctionData(connectorClient, makeLegacyRegistrationParams(data)) return commitName.makeFunctionData(connectorClient, data) } diff --git a/src/transaction-flow/transaction/registerName.test.ts b/src/transaction-flow/transaction/registerName.test.ts index d0e8ed825..e6ad2968f 100644 --- a/src/transaction-flow/transaction/registerName.test.ts +++ b/src/transaction-flow/transaction/registerName.test.ts @@ -3,7 +3,7 @@ import { mockFunction } from '@app/test-utils' import { expect, it, vi } from 'vitest' import { getPrice } from '@ensdomains/ensjs/public' -import { registerName } from '@ensdomains/ensjs/wallet' +import { registerName, legacyRegisterName } from '@ensdomains/ensjs/wallet' import registerNameFlowTransaction from './registerName' @@ -12,9 +12,11 @@ vi.mock('@ensdomains/ensjs/wallet') const mockGetPrice = mockFunction(getPrice) const mockRegisterName = mockFunction(registerName.makeFunctionData) +const mockLegacyRegisterName = mockFunction(legacyRegisterName.makeFunctionData) mockGetPrice.mockImplementation(async () => ({ base: 100n, premium: 0n })) mockRegisterName.mockImplementation((...args: any[]) => args as any) +mockLegacyRegisterName.mockImplementation((...args: any[]) => args as any) it('adds a 2% value buffer to the transaction from the real price', async () => { const result = (await registerNameFlowTransaction.transaction({ diff --git a/src/transaction-flow/transaction/registerName.ts b/src/transaction-flow/transaction/registerName.ts index 3a1d95dfb..a6bcfafc9 100644 --- a/src/transaction-flow/transaction/registerName.ts +++ b/src/transaction-flow/transaction/registerName.ts @@ -2,11 +2,14 @@ import type { TFunction } from 'react-i18next' import { getPrice } from '@ensdomains/ensjs/public' import { RegistrationParameters } from '@ensdomains/ensjs/utils' -import { registerName } from '@ensdomains/ensjs/wallet' +import { legacyRegisterName, registerName } from '@ensdomains/ensjs/wallet' import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' +import { isLegacyRegistration } from '@app/utils/registration/isLegacyRegistration' import { calculateValueWithBuffer, formatDurationOfDates } from '@app/utils/utils' +import { makeLegacyRegistrationParams } from '../../utils/registration/makeLegacyRegistrationParams' + type Data = RegistrationParameters const now = Math.floor(Date.now()) const displayItems = ( @@ -41,6 +44,11 @@ const transaction = async ({ const value = price.base + price.premium const valueWithBuffer = calculateValueWithBuffer(value) + if (isLegacyRegistration(data)) + return legacyRegisterName.makeFunctionData(connectorClient, { + ...makeLegacyRegistrationParams(data), + value: valueWithBuffer, + }) return registerName.makeFunctionData(connectorClient, { ...data, value: valueWithBuffer, diff --git a/src/utils/chains/makeLocalhostChainWithEns.ts b/src/utils/chains/makeLocalhostChainWithEns.ts index 295e9c1e8..d1836de60 100644 --- a/src/utils/chains/makeLocalhostChainWithEns.ts +++ b/src/utils/chains/makeLocalhostChainWithEns.ts @@ -43,6 +43,12 @@ export const makeLocalhostChainWithEns = ( ensDnssecImpl: { address: deploymentAddresses_.DNSSECImpl, }, + legacyEthRegistrarController: { + address: deploymentAddresses_.LegacyETHRegistrarController, + }, + legacyPublicResolver: { + address: deploymentAddresses_.LegacyPublicResolver, + }, }, subgraphs: { ens: { diff --git a/src/utils/coin.ts b/src/utils/coin.ts index 74bf3d1ca..a77145b50 100644 --- a/src/utils/coin.ts +++ b/src/utils/coin.ts @@ -1,5 +1,8 @@ import { getAddress } from 'viem' +export const isEthCoin = (coin: string | number): boolean => + (typeof coin === 'string' && coin.toLowerCase() === 'eth') || coin === 60 + export const normalizeCoinAddress = ({ coin, address, @@ -8,7 +11,7 @@ export const normalizeCoinAddress = ({ address?: string | null }): string => { if (!address) return '' - if (coin === 'eth' || coin === 'ETH' || coin === 60) { + if (isEthCoin(coin)) { try { return getAddress(address) } catch { diff --git a/src/utils/registration/isLegacyRegistration.test.ts b/src/utils/registration/isLegacyRegistration.test.ts new file mode 100644 index 000000000..d6e1b11d1 --- /dev/null +++ b/src/utils/registration/isLegacyRegistration.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' + +import { RegistrationParameters } from '@ensdomains/ensjs/utils' + +import { isLegacyRegistration } from './isLegacyRegistration' + +describe('isLegacyRegistration', () => { + it('should return true when there are no records, fuses, or reverse record', () => { + const params = { + name: 'test', + owner: '0x123', + secret: '0x123', + duration: 100, + reverseRecord: false, + } as RegistrationParameters + expect(isLegacyRegistration(params)).toBe(true) + }) + + it('should return true when there are no records, fuses, or reverse record', () => { + const params = { + name: 'test', + owner: '0x123', + secret: '0x123', + duration: 100, + fuses: { + named: [], + unnamed: [], + }, + records: { + coins: [{ coin: 'eth', value: '0x123' }], + texts: [], + contentHash: '', + }, + reverseRecord: false, + } as RegistrationParameters + expect(isLegacyRegistration(params)).toBe(true) + }) + + it('should return false when there are records', () => { + const params = { + name: 'test', + owner: '0x123', + secret: '0x123', + duration: 100, + fuses: { + named: [], + unnamed: [], + }, + records: { + coins: [{ coin: 'eth', value: '0x123' }], + texts: [], + contentHash: '', + }, + reverseRecord: false, + } as RegistrationParameters + expect(isLegacyRegistration(params)).toBe(true) + }) + + it('should return false when there are records', () => { + const params = { + name: 'test', + owner: '0x123', + secret: '0x123', + duration: 100, + fuses: { + named: [], + unnamed: [], + }, + records: { + coins: [{ coin: 60, value: '0x123' }], + texts: [], + contentHash: '', + }, + reverseRecord: false, + } as RegistrationParameters + expect(isLegacyRegistration(params)).toBe(true) + }) + + it('should return false when there are records', () => { + const params = { + name: 'test', + owner: '0x123', + secret: '0x123', + duration: 100, + fuses: { + named: [], + unnamed: [], + }, + records: { + coins: [{ coin: 'ETH', value: '0x123' }], + texts: [], + contentHash: '', + }, + reverseRecord: false, + } as RegistrationParameters + expect(isLegacyRegistration(params)).toBe(true) + }) + + it('should return false when there are records', () => { + const params = { + name: 'test', + owner: '0x123', + secret: '0x123', + duration: 100, + fuses: { + named: [], + unnamed: [], + }, + records: { + coins: [ + { coin: 'eth', value: '0x123' }, + { coin: 'btc', value: '0x123' }, + ], + texts: [], + contentHash: '', + }, + reverseRecord: false, + } as RegistrationParameters + expect(isLegacyRegistration(params)).toBe(false) + }) + + it('should return false when there are records', () => { + const params = { + name: 'test', + owner: '0x123', + secret: '0x123', + duration: 100, + fuses: { + named: [], + unnamed: [], + }, + records: { + coins: [{ coin: 'ETH', value: '0x123' }], + texts: [], + contentHash: '', + }, + reverseRecord: false, + } as RegistrationParameters + expect(isLegacyRegistration(params)).toBe(true) + }) + + it('should return false when there are records', () => { + const params = { + name: 'test', + owner: '0x123', + secret: '0x123', + duration: 100, + fuses: { + named: [], + unnamed: [], + }, + records: { + coins: [{ coin: 'ETH', value: '0x123' }], + texts: [{ key: 'test', value: 'test' }], + contentHash: '', + }, + reverseRecord: false, + } as RegistrationParameters + expect(isLegacyRegistration(params)).toBe(false) + }) + + it('should return false when there are fuses', () => { + const params = { + name: 'test', + owner: '0x123', + secret: '0x123', + duration: 100, + fuses: { + named: ['CANNOT_APPROVE'], + unnamed: [], + }, + records: { + coins: [{ coin: 'eth', value: '0x123' }], + texts: [], + contentHash: '0xcontenthash', + }, + reverseRecord: false, + } as RegistrationParameters + expect(isLegacyRegistration(params)).toBe(false) + }) + + it('should return false when there is a reverse record', () => { + const params = { + name: 'test', + owner: '0x123', + secret: '0x123', + duration: 100, + fuses: { + named: [], + unnamed: [], + }, + records: { + coins: [{ coin: 'eth', value: '0x123' }], + texts: [], + contentHash: '', + }, + reverseRecord: true, + } as RegistrationParameters + expect(isLegacyRegistration(params)).toBe(false) + }) +}) diff --git a/src/utils/registration/isLegacyRegistration.ts b/src/utils/registration/isLegacyRegistration.ts new file mode 100644 index 000000000..8e02a0d7d --- /dev/null +++ b/src/utils/registration/isLegacyRegistration.ts @@ -0,0 +1,24 @@ +import { RegistrationParameters } from '@ensdomains/ensjs/utils' + +const hasFuses = ({ fuses }: RegistrationParameters) => { + const hasNamedFuses = fuses?.named && fuses.named.length > 0 + const hasUnnameFuses = fuses?.unnamed && fuses.unnamed.length > 0 + return hasNamedFuses || hasUnnameFuses +} + +const hasRecords = ({ records }: RegistrationParameters) => { + const hasAddressRecords = + records?.coins && + records.coins?.filter(({ coin }) => { + const cond1 = typeof coin === 'string' && coin.toLowerCase() !== 'eth' + const cond2 = typeof coin === 'number' && coin !== 60 + return cond1 || cond2 + }).length > 0 + const hasTextRecord = !!records?.texts && records.texts.length > 0 + const hasContentHash = !!records?.contentHash + return hasAddressRecords || hasTextRecord || hasContentHash +} + +export const isLegacyRegistration = (params: RegistrationParameters) => { + return !hasRecords(params) && !hasFuses(params) && !params.reverseRecord +} diff --git a/src/utils/registration/makeLegacyRegistrationParams.test.ts b/src/utils/registration/makeLegacyRegistrationParams.test.ts new file mode 100644 index 000000000..9f65dffd1 --- /dev/null +++ b/src/utils/registration/makeLegacyRegistrationParams.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' + +import { makeLegacyRegistrationParams } from './makeLegacyRegistrationParams' +import { RegistrationParameters } from '@ensdomains/ensjs/utils' + +describe('makeLegacyRegistrationParams', () => { + it('should return owner as address if no eth record exists', () => { + const params: RegistrationParameters = { + name: 'test', + owner: '0xowner', + duration: 100, + secret: '0xsecret', + resolverAddress: '0xresolverAddress', + } + + expect(makeLegacyRegistrationParams(params)).toEqual({ + name: 'test', + owner: '0xowner', + duration: 100, + secret: '0xsecret', + resolverAddress: '0xresolverAddress', + address: '0xowner', + }) + }) + + it('should return address from eth record if it exists', () => { + const params: RegistrationParameters = { + name: 'test', + owner: '0xowner', + duration: 100, + secret: '0xsecret', + resolverAddress: '0xresolverAddress', + records: { + coins: [{ coin: 'eth', value: '0xother' }], + } + } + + expect(makeLegacyRegistrationParams(params)).toEqual({ + name: 'test', + owner: '0xowner', + duration: 100, + secret: '0xsecret', + resolverAddress: '0xresolverAddress', + address: '0xother', + }) + }) +}) diff --git a/src/utils/registration/makeLegacyRegistrationParams.ts b/src/utils/registration/makeLegacyRegistrationParams.ts new file mode 100644 index 000000000..6b1231ee7 --- /dev/null +++ b/src/utils/registration/makeLegacyRegistrationParams.ts @@ -0,0 +1,29 @@ +import { Address } from 'viem' + +import { + LegacyRegistrationWithConfigParameters, + RegistrationParameters, +} from '@ensdomains/ensjs/utils' + +import { isEthCoin } from '../coin' +import { emptyAddress } from '../constants' + +export const makeLegacyRegistrationParams = ({ + name, + owner, + records, + duration, + secret, + resolverAddress = emptyAddress, +}: RegistrationParameters): LegacyRegistrationWithConfigParameters => { + const address = (records?.coins?.find(({ coin }) => isEthCoin(coin))?.value as Address) || owner + + return { + name, + owner, + duration, + secret, + resolverAddress, + address, + } as LegacyRegistrationWithConfigParameters +}