diff --git a/e2e/specs/stateless/extendNames.spec.ts b/e2e/specs/stateless/extendNames.spec.ts index 51ddd1e73..c799ccd54 100644 --- a/e2e/specs/stateless/extendNames.spec.ts +++ b/e2e/specs/stateless/extendNames.spec.ts @@ -18,6 +18,7 @@ test('should be able to register multiple names on the address page', async ({ subgraph, makePageObject, makeName, + time, }) => { // Generating names in not neccessary but we want to make sure that there are names to extend await makeName([ @@ -83,11 +84,16 @@ test('should be able to register multiple names on the address page', async ({ await transactionModal.autoComplete() await subgraph.sync() - await page.reload() await page.waitForTimeout(3000) + + // Should be able to remove this after useQuery is fixed. Using to force a refetch. + await time.increaseTime({ seconds: 15 }) + await page.reload() for (const name of extendableNameItems) { const label = name.replace('.eth', '') await addresPage.search(label) + await expect(addresPage.getNameRow(name)).toBeVisible({ timeout: 5000 }) + await page.pause() await expect(await addresPage.getTimestamp(name)).not.toBe(timestampDict[name]) await expect(await addresPage.getTimestamp(name)).toBe(timestampDict[name] + 31536000000 * 3) } diff --git a/e2e/specs/stateless/registerName.spec.ts b/e2e/specs/stateless/registerName.spec.ts index 5824d7696..d47b9a29b 100644 --- a/e2e/specs/stateless/registerName.spec.ts +++ b/e2e/specs/stateless/registerName.spec.ts @@ -29,6 +29,7 @@ test.describe.serial('normal registration', () => { accounts, time, makePageObject, + consoleListener, }) => { await setPrimaryName(walletClient, { name: '', @@ -39,8 +40,10 @@ test.describe.serial('normal registration', () => { const registrationPage = makePageObject('RegistrationPage') const transactionModal = makePageObject('TransactionModal') - await time.sync(500) - + await consoleListener.initialize({ + regex: /Event triggered on local development.*register-override-triggered/, + }) + await time.sync() await homePage.goto() await login.connect() @@ -100,6 +103,7 @@ test.describe.serial('normal registration', () => { await page.getByTestId('next-button').click() await transactionModal.closeButton.click() + await page.pause() await expect( page.getByText( 'You will need to complete two transactions to secure your name. The second transaction must be completed within 24 hours of the first.', @@ -117,15 +121,19 @@ test.describe.serial('normal registration', () => { ), ).toBeVisible() + await time.sync() + // should show countdown await expect(page.getByTestId('countdown-circle')).toBeVisible() - await expect(page.getByTestId('countdown-complete-check')).toBeVisible() + await expect(page.getByTestId('countdown-complete-check')).not.toBeVisible() const waitButton = page.getByTestId('wait-button') await expect(waitButton).toBeVisible() await expect(waitButton).toBeDisabled() + + await time.increaseTime({ seconds: 60 }) + await expect(page.getByTestId('countdown-complete-check')).toBeVisible() const startTimerButton = page.getByTestId('start-timer-button') await expect(startTimerButton).not.toBeVisible() - await testClient.increaseTime({ seconds: 60 }) // Should show registration text await expect( @@ -156,6 +164,10 @@ test.describe.serial('normal registration', () => { await expect(page.getByTestId('address-profile-button-eth')).toHaveText( accounts.getAddress('user', 5), ) + + await test.step('confirm that track event was not called', async () => { + await expect(consoleListener.getMessages()).toHaveLength(0) + }) }) test('should not direct to the registration page on search, and show all records from registration', async ({ @@ -931,3 +943,119 @@ test('should be able to detect an existing commit created on a private mempool', ) }) }) + +test.describe('Error handling', () => { + test('should be able to detect an existing commit created on a private mempool', async ({ + page, + login, + time, + makePageObject, + }) => { + test.slow() + + const homePage = makePageObject('HomePage') + const registrationPage = makePageObject('RegistrationPage') + const transactionModal = makePageObject('TransactionModal') + + await time.sync() + + await homePage.goto() + await login.connect() + + const name = `expired-commit-${Date.now()}.eth` + // 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 test.step('pricing page', async () => { + await page.getByTestId('payment-choice-ethereum').check() + await registrationPage.primaryNameToggle.uncheck() + await page.getByTestId('next-button').click() + }) + + await test.step('info page', async () => { + await expect(page.getByTestId('next-button')).toHaveText('Begin') + await page.getByTestId('next-button').click() + }) + + await test.step('transaction: commit', async () => { + await expect(page.getByText('Open Wallet')).toBeVisible() + await transactionModal.confirm() + await expect(page.getByText(`Your "Start timer" transaction was successful`)).toBeVisible() + await time.sync() + await page.waitForTimeout(1000) + await time.increaseTime({ seconds: 60 * 60 * 24 }) + }) + + await expect( + page.getByText('Your registration has expired. You will need to start the process again.'), + ).toBeVisible() + await expect(page.getByRole('button', { name: 'Restart' })).toBeVisible() + await expect(page.getByTestId('finish-button')).toHaveCount(0) + }) + + test('should be able to register name if the commit transaction does not update', async ({ + page, + login, + accounts, + time, + makePageObject, + consoleListener, + }) => { + test.slow() + + const homePage = makePageObject('HomePage') + const registrationPage = makePageObject('RegistrationPage') + const transactionModal = makePageObject('TransactionModal') + + await time.sync() + await consoleListener.initialize({ + regex: /Event triggered on local development.*register-override-triggered/, + }) + await homePage.goto() + await login.connect() + + const name = `stuck-commit-${Date.now()}.eth` + // 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 test.step('pricing page', async () => { + await page.getByTestId('payment-choice-ethereum').check() + await registrationPage.primaryNameToggle.uncheck() + await page.getByTestId('next-button').click() + }) + + await test.step('info page', async () => { + await expect(page.getByTestId('next-button')).toHaveText('Begin') + await page.getByTestId('next-button').click() + }) + + await test.step('transaction: commit', async () => { + await expect(page.getByText('Open Wallet')).toBeVisible() + await transactionModal.confirm() + await expect(page.getByText(`Your "Start timer" transaction was successful`)).toBeVisible() + await time.increaseTimeByTimestamp({ seconds: 120 }) + }) + + await test.step('transaction: register', async () => { + await expect(page.getByTestId('finish-button')).toBeVisible({ timeout: 10000 }) + await expect(page.getByTestId('finish-button')).toBeEnabled() + + await page.getByTestId('finish-button').click() + await expect(page.getByText('Open Wallet')).toBeVisible() + 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 plausible event was fired once', async () => { + expect(consoleListener.getMessages()).toHaveLength(1) + }) + }) +}) diff --git a/package.json b/package.json index 963a5cc1f..430a5343d 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "@nomiclabs/hardhat-ethers": "npm:hardhat-deploy-ethers@^0.3.0-beta.13", "@openzeppelin/contracts": "^4.7.3", "@openzeppelin/test-helpers": "^0.5.16", - "@playwright/test": "^1.36.2", + "@playwright/test": "^1.45.0", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.0.0", "@testing-library/react-hooks": "^8.0.1", diff --git a/playwright/fixtures/consoleListener.ts b/playwright/fixtures/consoleListener.ts new file mode 100644 index 000000000..3c4424ee2 --- /dev/null +++ b/playwright/fixtures/consoleListener.ts @@ -0,0 +1,30 @@ +import { ConsoleMessage, Page } from "@playwright/test" + +type Dependencies = { + page: Page +} + +export const createConsoleListener = ({ page}: Dependencies) => { + let messages: string[] = [] + let internalRegex: RegExp | null = null + + const filter = (msg: ConsoleMessage) => { + const message = msg.text() + if (internalRegex?.test(message)) messages.push(message) + } + + return { + initialize: ({ regex}: { regex: RegExp}) => { + messages.length = 0 + internalRegex = regex + page.on('console', filter) + }, + reset: () => { + messages.length = 0 + internalRegex = null + page.off('console', filter) + }, + print: () => console.log(messages), + getMessages: () => messages + } +} \ No newline at end of file diff --git a/playwright/fixtures/time.ts b/playwright/fixtures/time.ts index 447ed26bf..17d03971d 100644 --- a/playwright/fixtures/time.ts +++ b/playwright/fixtures/time.ts @@ -1,7 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies */ import { Page } from '@playwright/test' -import { publicClient } from './contracts/utils/addTestContracts' +import { publicClient, testClient } from './contracts/utils/addTestContracts' export type Time = ReturnType @@ -11,33 +11,50 @@ type Dependencies = { export const createTime = ({ page }: Dependencies) => { return { + // Offset is used to set the browser forward in time. This is useful for testing contract where + // the contract relies on block timestamp, but anvil's block timestamp is unpredictable. sync: async (offset = 0) => { - const browserTime = await page.evaluate(() => Math.floor(Date.now() / 1000)) const blockTime = Number((await publicClient.getBlock()).timestamp) - const browserOffset = (blockTime - browserTime + offset) * 1000 - - console.log(`Browser time: ${new Date(Date.now() + browserOffset)}`) - - await page.addInitScript(`{ - // Prevents Date from being extended multiple times - if (Object.getPrototypeOf(Date).name !== 'Date') { - const __DateNow = Date.now - const browserOffset = ${browserOffset}; - Date = class extends Date { - constructor(...args) { - if (args.length === 0) { - super(__DateNow() + browserOffset); - } else { - super(...args); - } - } - - static now() { - return super.now() + browserOffset; - } - } - } - }`) + const time = new Date((blockTime + offset) * 1000) + console.log(`Browser time: ${time}`) + await page.clock.install({ time }) + }, + logBlockTime: async () => { + const blockTime = Number((await publicClient.getBlock()).timestamp) + console.log(`Block time: ${new Date(blockTime * 1000)}`) }, + logBrowserTime: async () => { + const time = await page.evaluate(() => new Date().toString()) + console.log(`Browser time: ${time}`) + }, + syncFixed: async () => { + const blockTime = Number((await publicClient.getBlock()).timestamp) + const time = new Date(blockTime * 1000) + await page.clock.setFixedTime(time) + console.log(`Fixed Browser time: ${time}`, blockTime) + }, + increaseTime: async ({ seconds }: { seconds: number }) => { + await testClient.increaseTime({ seconds }) + await page.clock.fastForward(seconds * 1000) + }, + increaseTimeByTimestamp: async ({ seconds }: { seconds: number }) => { + const tryIncreaseTime = async () => { + try { + const blockTimestamp = Number((await publicClient.getBlock()).timestamp) + await testClient.setNextBlockTimestamp({ timestamp: BigInt(blockTimestamp + seconds) }) + await testClient.mine({ blocks: 1 }) + return true + } catch { + return false + } + } + + let success = false + let attempts = 0 + while (!success && attempts < 3) { + success = await tryIncreaseTime() + attempts += 1 + } + } } } diff --git a/playwright/index.ts b/playwright/index.ts index 40bf2a3c0..5c01e69d4 100644 --- a/playwright/index.ts +++ b/playwright/index.ts @@ -10,6 +10,7 @@ import { createMakeNames } from './fixtures/makeName/index.js' import { createSubgraph } from './fixtures/subgraph.js' import { createTime } from './fixtures/time.js' import { createPageObjectMaker } from './pageObjects/index.js' +import { createConsoleListener } from './fixtures/consoleListener' type Fixtures = { accounts: Accounts @@ -20,6 +21,7 @@ type Fixtures = { makePageObject: ReturnType subgraph: ReturnType time: ReturnType + consoleListener: ReturnType } export const test = base.extend({ @@ -57,4 +59,9 @@ export const test = base.extend({ time: async ({ page }, use) => { await use(createTime({ page })) }, + consoleListener: async ({ page }, use) => { + const consoleListener = createConsoleListener({ page }) + await use(consoleListener) + consoleListener.reset() + } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c128dcb7..acb6471a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,8 +202,8 @@ importers: specifier: ^0.5.16 version: 0.5.16(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10) '@playwright/test': - specifier: ^1.36.2 - version: 1.44.1 + specifier: ^1.45.0 + version: 1.47.2 '@testing-library/jest-dom': specifier: ^6.4.2 version: 6.4.5(@types/jest@29.5.12)(vitest@2.0.5(@types/node@18.19.33)(jsdom@24.1.0(bufferutil@4.0.8)(canvas@2.11.2(encoding@0.1.13))(utf-8-validate@5.0.10))(terser@5.31.5)) @@ -2574,9 +2574,9 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.44.1': - resolution: {integrity: sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==} - engines: {node: '>=16'} + '@playwright/test@1.47.2': + resolution: {integrity: sha512-jTXRsoSPONAs8Za9QEQdyjFn+0ZQFjCiIztAIF6bi1HqhBzG9Ma7g1WotyiGqFSBRZjIEqMdT8RUlbk1QVhzCQ==} + engines: {node: '>=18'} hasBin: true '@polka/url@1.0.0-next.25': @@ -7897,14 +7897,14 @@ packages: pkg-types@1.1.1: resolution: {integrity: sha512-ko14TjmDuQJ14zsotODv7dBlwxKhUKQEhuhmbqo1uCi9BB0Z2alo/wAXg6q1dTR5TyuqYyWhjtfe/Tsh+X28jQ==} - playwright-core@1.44.1: - resolution: {integrity: sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==} - engines: {node: '>=16'} + playwright-core@1.47.2: + resolution: {integrity: sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==} + engines: {node: '>=18'} hasBin: true - playwright@1.44.1: - resolution: {integrity: sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==} - engines: {node: '>=16'} + playwright@1.47.2: + resolution: {integrity: sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==} + engines: {node: '>=18'} hasBin: true pngjs@5.0.0: @@ -12986,9 +12986,9 @@ snapshots: '@pkgr/core@0.1.1': {} - '@playwright/test@1.44.1': + '@playwright/test@1.47.2': dependencies: - playwright: 1.44.1 + playwright: 1.47.2 '@polka/url@1.0.0-next.25': {} @@ -19931,11 +19931,11 @@ snapshots: mlly: 1.7.0 pathe: 1.1.2 - playwright-core@1.44.1: {} + playwright-core@1.47.2: {} - playwright@1.44.1: + playwright@1.47.2: dependencies: - playwright-core: 1.44.1 + playwright-core: 1.47.2 optionalDependencies: fsevents: 2.3.2 diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 0169d77dd..8a0dbb1e7 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -27,6 +27,7 @@ "remove": "Remove", "sign": "Sign", "reset": "Reset", + "restart": "Restart", "transfer": "Transfer", "tryAgain": "Try Again", "done": "Done", diff --git a/public/locales/en/register.json b/public/locales/en/register.json index 3ef9d18e3..27376de4d 100644 --- a/public/locales/en/register.json +++ b/public/locales/en/register.json @@ -184,16 +184,18 @@ "heading": "Almost there", "subheading": { "default": "You will need to complete two transactions to secure your name. The second transaction must be completed within 24 hours of the first.", + "commitSent": "Your first transaction is in progress.
The second transaction must be completed within 24 hours of the first.", "commiting": "This wait prevents others from front running your transaction. You will be prompted to complete a second transaction when the timer is complete.", - "commitComplete": "Your name is not registered until you’ve completed the second transaction. You have {{duration}} remaining to complete it.", - "commitCompleteNoDuration": "Your name is not registered until you’ve completed the second transaction.", + "commitComplete": "Your name is not registered until you’ve completed the second transaction.
You have {{duration}} remaining to complete it.", + "commitCompleteNoDuration": "Your name is not registered until you’ve completed the second transaction.", "commitExpired": "Your registration has expired. You will need to start the process again.", "frontRunning": "When someone sees your transaction and registers the name before your transaction can complete." }, "startTimer": "Start timer", "wait": "Wait", + "completeRegistration": "Complete registration", "transactionFailed": "Transaction Failed", - "transactionProgress": "Transaction in progress" + "transactionProgress": "View transaction in progress" }, "cancelRegistration": { "heading": "You will lose your transaction", diff --git a/src/components/@atoms/StatusDots/StatusDots.tsx b/src/components/@atoms/StatusDots/StatusDots.tsx new file mode 100644 index 000000000..adf003c65 --- /dev/null +++ b/src/components/@atoms/StatusDots/StatusDots.tsx @@ -0,0 +1,57 @@ +import styled, { css, keyframes } from 'styled-components' + +import { Colors, DefaultTheme } from '@ensdomains/thorin' + +const dotOneAnimation = (theme: DefaultTheme, color: Colors) => keyframes` + 0% { background-color: ${theme.colors[color]} } + 14.285% { background-color: ${theme.colors.accentPrimary} } + 42.857% { background-color: ${theme.colors.accentPrimary} } + 57.142% { background-color: ${theme.colors[color]} } +` + +const Dot = styled.div( + ({ theme }) => css` + width: ${theme.space['4']}; + height: ${theme.space['4']}; + border-radius: 50%; + `, +) + +const Container = styled.div<{ $animate: boolean; $color: Colors }>( + ({ theme, $animate, $color }) => css` + width: ${theme.space['22.5']}; + height: ${theme.space['4']}; + display: flex; + justify-content: space-between; + padding: 0 ${theme.space['0.25']}; + + > div { + background-color: ${theme.colors[$color]}; + ${$animate && + css` + animation: ${dotOneAnimation(theme, $color)} 1050ms infinite; + `} + } + + > div:nth-child(2) { + animation-delay: 150ms; + } + > div:nth-child(3) { + animation-delay: 300ms; + } + > div:nth-child(4) { + animation-delay: 450ms; + } + `, +) + +export const StatusDots = ({ animate, color }: { animate: boolean; color: Colors }) => { + return ( + + + + + + + ) +} diff --git a/src/components/pages/profile/[name]/registration/steps/Transactions.tsx b/src/components/pages/profile/[name]/registration/steps/Transactions.tsx index 6fa915441..4e245ea57 100644 --- a/src/components/pages/profile/[name]/registration/steps/Transactions.tsx +++ b/src/components/pages/profile/[name]/registration/steps/Transactions.tsx @@ -8,18 +8,79 @@ import { makeCommitment } from '@ensdomains/ensjs/utils' import { Button, CountdownCircle, Dialog, Heading, mq, Spinner } from '@ensdomains/thorin' import MobileFullWidth from '@app/components/@atoms/MobileFullWidth' +import { StatusDots } from '@app/components/@atoms/StatusDots/StatusDots' import { TextWithTooltip } from '@app/components/@atoms/TextWithTooltip/TextWithTooltip' import { Card } from '@app/components/Card' +import { useChainName } from '@app/hooks/chain/useChainName' import { useExistingCommitment } from '@app/hooks/registration/useExistingCommitment' +import { useSimulateRegistration } from '@app/hooks/registration/useSimulateRegistration' import { useDurationCountdown } from '@app/hooks/time/useDurationCountdown' 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 { trackEvent } from '@app/utils/analytics' import { ONE_DAY } from '@app/utils/time' import { RegistrationReducerDataItem } from '../types' +const PATTERNS = { + RegistrationComplete: { + commitComplete: P._, + canRegisterOverride: P._, + commitStage: P._, + registerStage: 'complete', + }, + RegistrationFailed: { + commitComplete: P._, + canRegisterOverride: P._, + commitStage: P._, + registerStage: 'failed', + }, + RegistrationSent: { + commitComplete: P._, + canRegisterOverride: P._, + commitStage: P._, + registerStage: 'sent', + }, + RegistrationReady: { + commitComplete: true, + canRegisterOverride: P._, + commitStage: P._, + registerStage: P.union(P.nullish, 'confirm' as const), + }, + RegistrationOverriden: { + commitComplete: P._, + canRegisterOverride: true, + commitStage: P._, + registerStage: P.union(P.nullish, 'confirm' as const), + }, + CommitFailed: { + commitComplete: P._, + canRegisterOverride: P._, + commitStage: 'failed', + registerStage: P._, + }, + CommitComplete: { + commitComplete: false, + canRegisterOverride: P._, + commitStage: 'complete', + registerStage: P.union(P.nullish, 'confirm' as const), + }, + CommitSent: { + commitComplete: false, + canRegisterOverride: false, + commitStage: 'sent', + registerStage: P.union(P.nullish, 'confirm' as const), + }, + CommitReady: { + commitComplete: false, + canRegisterOverride: false, + commitStage: P.union(P.nullish, 'confirm' as const), + registerStage: P.union(P.nullish, 'confirm' as const), + }, +} as const + const StyledCard = styled(Card)( ({ theme }) => css` max-width: 780px; @@ -46,6 +107,12 @@ const ButtonContainer = styled.div( `, ) +const CountdownContainer = styled.div( + () => css` + position: relative; + `, +) + const StyledCountdown = styled(CountdownCircle)( ({ theme, disabled }) => css` width: ${theme.space['52']}; @@ -58,22 +125,48 @@ const StyledCountdown = styled(CountdownCircle)( color: ${theme.colors.accent}; ${disabled && css` - color: ${theme.colors.grey}; + color: ${theme.colors.border}; `} } + svg { stroke-width: ${theme.space['0.5']}; + stroke: ${theme.colors.accentPrimary}; ${disabled && css` - stroke: ${theme.colors.grey}; + stroke: ${theme.colors.border}; `} } + + svg#countdown-complete-check { + width: ${theme.space['16']}; + height: ${theme.space['16']}; + stroke: initial; + } + `, +) + +const CountDownInner = styled.div<{ $hide: boolean }>( + ({ theme, $hide }) => css` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100px; + height: 100px; + background-color: ${theme.colors.background}; + display: flex; + justify-content: center; + align-items: center; + border-radius: ${theme.radii.full}; + opacity: ${$hide ? 0 : 1}; + transition: opacity 0.3s ease-in-out; `, ) const FailedButton = ({ onClick, label }: { onClick: () => void; label: string }) => ( - @@ -101,23 +194,51 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { const keySuffix = `${name}-${address}` const commitKey = `commit-${keySuffix}` const registerKey = `register-${keySuffix}` - const { getLatestTransaction, createTransactionFlow, resumeTransactionFlow, cleanupFlow } = - useTransactionFlow() + const { + getSelectedKey, + getLatestTransaction, + createTransactionFlow, + resumeTransactionFlow, + cleanupFlow, + stopCurrentFlow, + } = useTransactionFlow() const commitTx = getLatestTransaction(commitKey) const registerTx = getLatestTransaction(registerKey) const [resetOpen, setResetOpen] = useState(false) - const commitTimestamp = commitTx?.stage === 'complete' ? commitTx?.finaliseTime : undefined - const [commitComplete, setCommitComplete] = useState( - !!commitTimestamp && commitTimestamp + 60000 < Date.now(), - ) - const registrationParams = useRegistrationParams({ name, owner: address!, registrationData, }) + const { isSuccess: canRegisterOverride } = useSimulateRegistration({ + registrationParams, + query: { + enabled: commitTx?.stage === 'sent', + retry: true, + retryDelay: 5_000, + }, + }) + + const chainName = useChainName() + useEffect(() => { + if (canRegisterOverride && commitTx?.stage !== 'complete') { + trackEvent('register-override-triggered', chainName) + if (getSelectedKey() === commitKey) stopCurrentFlow() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canRegisterOverride, chainName]) + + const commitTimestamp = match({ commitStage: commitTx?.stage, canRegisterOverride }) + .with({ canRegisterOverride: true }, () => Math.floor(Date.now() / 1000) - 60) + .with({ commitStage: 'complete' }, () => commitTx?.finaliseTime) + .otherwise(() => undefined) + + const [commitComplete, setCommitComplete] = useState( + !!commitTimestamp && commitTimestamp + 60000 < Date.now(), + ) + const commitCouldBeFound = !commitTx?.stage || commitTx.stage === 'confirm' || commitTx.stage === 'failed' useExistingCommitment({ @@ -126,6 +247,23 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { commitKey, }) + const transactionState = match({ + commitComplete, + canRegisterOverride, + commitStage: commitTx?.stage, + registerStage: registerTx?.stage, + }) + .with(PATTERNS.RegistrationComplete, () => 'registrationComplete' as const) + .with(PATTERNS.RegistrationFailed, () => 'registrationFailed' as const) + .with(PATTERNS.RegistrationSent, () => 'registrationSent' as const) + .with(PATTERNS.RegistrationOverriden, () => 'registrationOverriden' as const) + .with(PATTERNS.RegistrationReady, () => 'registrationReady' as const) + .with(PATTERNS.CommitFailed, () => 'commitFailed' as const) + .with(PATTERNS.CommitComplete, () => 'commitComplete' as const) + .with(PATTERNS.CommitSent, () => 'commitSent' as const) + .with(PATTERNS.CommitReady, () => 'commitReady' as const) + .exhaustive() + const makeCommitNameFlow = useCallback(() => { onStart() createTransactionFlow(commitKey, { @@ -198,6 +336,8 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { endDate: commitTimestamp ? new Date(commitTimestamp + ONE_DAY * 1000) : undefined, }) + console.log('duration', duration, commitTimestamp) + return ( setResetOpen(false)}> @@ -220,16 +360,63 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { /> {t('steps.transactions.heading')} - setCommitComplete(true)} - /> + + true) + .otherwise(() => false)} + startTimestamp={commitTimestamp} + size="large" + callback={() => setCommitComplete(true)} + /> + true) + .with( + 'registrationReady', + () => duration !== null, + () => true, + ) + .otherwise(() => false)} + > + true) + .otherwise(() => false)} + color={match(transactionState) + .with( + 'commitReady', + 'commitSent', + 'commitComplete', + 'commitFailed', + () => 'border' as const, + ) + .otherwise(() => 'blueLight' as const)} + /> + + - {match([commitTx, commitComplete, duration]) - .with([{ stage: 'complete' }, false, P._], () => ( + {match(transactionState) + .with('registrationComplete', () => '') + .with('registrationOverriden', () => ( + + )) + .with('registrationReady', 'registrationSent', 'registrationFailed', () => + match(duration) + .with(P.not(P.nullish), () => ( + + )) + .with(null, () => t('steps.transactions.subheading.commitExpired')) + .otherwise(() => ( + + )), + ) + .with('commitComplete', () => ( { }} /> )) - .with([{ stage: 'complete' }, true, null], () => - t('steps.transactions.subheading.commitExpired'), - ) - .with([{ stage: 'complete' }, true, P.not(P.nullish)], ([, , d]) => - t('steps.transactions.subheading.commitComplete', { duration: d }), - ) - .with([{ stage: 'complete' }, true, P._], () => - t('steps.transactions.subheading.commitCompleteNoDuration'), - ) - .otherwise(() => t('steps.transactions.subheading.default'))} + .with('commitSent', () => ( + + )) + .with('commitReady', 'commitFailed', () => t('steps.transactions.subheading.default')) + .exhaustive()} - {match([commitComplete, registerTx, commitTx]) - .with([true, { stage: 'failed' }, P._], () => ( + {match(transactionState) + .with('registrationComplete', () => null) + .with('registrationFailed', () => ( <> {ResetBackButton} { /> )) - .with([true, { stage: 'sent' }, P._], () => ( + .with('registrationSent', () => ( )) - .with([true, P._, P._], () => ( + .with( + 'registrationReady', + () => duration === null, + () => ( +
+ +
+ ), + ) + .with('registrationReady', 'registrationOverriden', () => ( <> {ResetBackButton} @@ -278,12 +472,12 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { data-testid="finish-button" onClick={!registerTx ? makeRegisterNameFlow : showRegisterTransaction} > - {t('action.finish', { ns: 'common' })} + {t('steps.transactions.completeRegistration')} )) - .with([false, P._, { stage: 'failed' }], () => ( + .with('commitFailed', () => ( <> {NormalBackButton} { /> )) - .with([false, P._, { stage: 'sent' }], () => ( - - )) - .with([false, P._, { stage: 'complete' }], () => ( + .with('commitComplete', () => ( <> {ResetBackButton} @@ -308,7 +496,13 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { )) - .otherwise(() => ( + .with('commitSent', () => ( + + )) + .with('commitReady', () => ( <> - ))} + )) + .exhaustive()}
) diff --git a/src/hooks/registration/useSimulateRegistration.ts b/src/hooks/registration/useSimulateRegistration.ts new file mode 100644 index 000000000..962e311ea --- /dev/null +++ b/src/hooks/registration/useSimulateRegistration.ts @@ -0,0 +1,37 @@ +import { usePublicClient, useSimulateContract, UseSimulateContractParameters } from 'wagmi' + +import { ethRegistrarControllerRegisterSnippet } from '@ensdomains/ensjs/contracts' +import { makeRegistrationTuple, RegistrationParameters } from '@ensdomains/ensjs/utils' + +import { calculateValueWithBuffer } from '@app/utils/utils' + +import { usePrice } from '../ensjs/public/usePrice' + +type UseSimulateRegistrationParameters = Pick & { + registrationParams: RegistrationParameters +} + +export const useSimulateRegistration = ({ + registrationParams, + query, +}: UseSimulateRegistrationParameters) => { + const client = usePublicClient() + + const { data: price } = usePrice({ + nameOrNames: registrationParams.name, + duration: registrationParams.duration, + }) + + const base = price?.base ?? 0n + const premium = price?.premium ?? 0n + const value = base + premium + + return useSimulateContract({ + abi: ethRegistrarControllerRegisterSnippet, + address: client.chain.contracts.ensEthRegistrarController.address, + functionName: 'register', + args: makeRegistrationTuple(registrationParams), + value: calculateValueWithBuffer(value), + query, + }) +} diff --git a/src/transaction-flow/TransactionFlowProvider.tsx b/src/transaction-flow/TransactionFlowProvider.tsx index 301ecc0f6..ef3003a73 100644 --- a/src/transaction-flow/TransactionFlowProvider.tsx +++ b/src/transaction-flow/TransactionFlowProvider.tsx @@ -35,6 +35,7 @@ type ProviderValue = { usePreparedDataInput: UsePreparedDataInput createTransactionFlow: CreateTransactionFlow resumeTransactionFlow: (key: string) => void + getSelectedKey: () => string | null getTransactionIndex: (key: string) => number getResumable: (key: string) => boolean getTransactionFlowStage: ( @@ -50,6 +51,7 @@ const TransactionContext = React.createContext({ usePreparedDataInput: () => () => {}, createTransactionFlow: () => {}, resumeTransactionFlow: () => {}, + getSelectedKey: () => null, getTransactionIndex: () => 0, getResumable: () => false, getTransactionFlowStage: () => 'undefined', @@ -83,6 +85,8 @@ export const TransactionFlowProvider = ({ children }: { children: ReactNode }) = }, ) + const getSelectedKey = useCallback(() => state.selectedKey, [state.selectedKey]) + const getTransactionIndex = useCallback( (key: string) => state.items[key]?.currentTransaction || 0, [state.items], @@ -187,6 +191,7 @@ export const TransactionFlowProvider = ({ children }: { children: ReactNode }) = payload: flow, })) as CreateTransactionFlow, resumeTransactionFlow, + getSelectedKey, getTransactionIndex, getTransaction, getResumable, @@ -200,6 +205,7 @@ export const TransactionFlowProvider = ({ children }: { children: ReactNode }) = dispatch, resumeTransactionFlow, getResumable, + getSelectedKey, getTransactionIndex, getLatestTransaction, getTransactionFlowStage, diff --git a/src/transaction-flow/reducer.ts b/src/transaction-flow/reducer.ts index 585dbf0c5..59b432db1 100644 --- a/src/transaction-flow/reducer.ts +++ b/src/transaction-flow/reducer.ts @@ -7,6 +7,7 @@ import { TransactionFlowAction, TransactionFlowStage, } from './types' +import { shouldSkipTransactionUpdateDuringTest } from './utils/shouldSkipTransactionUpdateDuringTest' export const initialState: InternalTransactionFlow = { selectedKey: null, @@ -169,6 +170,9 @@ export const reducer = (draft: InternalTransactionFlow, action: TransactionFlowA transaction.stage = 'sent' break } + + if (shouldSkipTransactionUpdateDuringTest(transaction)) break + const stage = status === 'confirmed' ? 'complete' : 'failed' transaction.stage = stage transaction.minedData = minedData diff --git a/src/transaction-flow/utils/isTransaction.ts b/src/transaction-flow/utils/isTransaction.ts new file mode 100644 index 000000000..a69eae0d2 --- /dev/null +++ b/src/transaction-flow/utils/isTransaction.ts @@ -0,0 +1,10 @@ +import type { TransactionData, TransactionName } from '../transaction' +import type { GenericTransaction } from '../types' + +export const isTransaction = + (name: TName) => + ( + transaction: GenericTransaction>, + ): transaction is GenericTransaction> => { + return transaction?.name === name + } diff --git a/src/transaction-flow/utils/shouldSkipTransactionUpdateDuringTest.ts b/src/transaction-flow/utils/shouldSkipTransactionUpdateDuringTest.ts new file mode 100644 index 000000000..ce23d657e --- /dev/null +++ b/src/transaction-flow/utils/shouldSkipTransactionUpdateDuringTest.ts @@ -0,0 +1,14 @@ +import { TransactionData, TransactionName } from '../transaction' +import { GenericTransaction } from '../types' +import { isTransaction } from './isTransaction' + +// This function is used to skip a transaction update during testing on a local chain environment. +export const shouldSkipTransactionUpdateDuringTest = ( + transaction: GenericTransaction>, +) => { + return ( + process.env.NEXT_PUBLIC_ETH_NODE === 'anvil' && + isTransaction('commitName')(transaction) && + transaction.data?.name?.startsWith('stuck-commit') + ) +}