diff --git a/e2e/specs/stateless/registerName.spec.ts b/e2e/specs/stateless/registerName.spec.ts index 19c8dc0e0..932846570 100644 --- a/e2e/specs/stateless/registerName.spec.ts +++ b/e2e/specs/stateless/registerName.spec.ts @@ -1,5 +1,7 @@ import { expect } from '@playwright/test' +import { Hash, isHash } from 'viem' +import { ethRegistrarControllerCommitSnippet } from '@ensdomains/ensjs/contracts' import { setPrimaryName } from '@ensdomains/ensjs/wallet' // import { secondsToDateInput } from '@app/utils/date' @@ -7,7 +9,11 @@ import { daysToSeconds, yearsToSeconds } from '@app/utils/time' import { test } from '../../../playwright' import { createAccounts } from '../../../playwright/fixtures/accounts' -import { walletClient } from '../../../playwright/fixtures/contracts/utils/addTestContracts' +import { + testClient, + waitForTransaction, + walletClient, +} from '../../../playwright/fixtures/contracts/utils/addTestContracts' /* * NOTE: Do not use transactionModal autocomplete here since the app will auto close the modal and playwright will @@ -835,3 +841,92 @@ test('should not allow normal registration less than 28 days', async ({ accounts.getAddress('user', 5), ) }) + +test('should be able to detect an existing commit created on a private mempool', async ({ + page, + login, + accounts, + provider, + time, + makePageObject, +}) => { + test.slow() + + const name = `registration-normal-${Date.now()}.eth` + const homePage = makePageObject('HomePage') + const registrationPage = makePageObject('RegistrationPage') + const transactionModal = makePageObject('TransactionModal') + + 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 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() + + const approveTx = await walletClient.writeContract({ + abi: ethRegistrarControllerCommitSnippet, + address: testClient.chain.contracts.ensEthRegistrarController.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 provider.increaseTime(60) + await expect(page.getByTestId('countdown-complete-check')).toBeVisible({ timeout: 10000 }) + 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.confirm() + + await page.getByTestId('view-name').click() + await expect(page.getByTestId('address-profile-button-eth')).toHaveText( + accounts.getAddress('user', 5), + ) +}) diff --git a/src/components/pages/profile/[name]/registration/steps/Transactions.tsx b/src/components/pages/profile/[name]/registration/steps/Transactions.tsx index 08da654ac..43573cb2a 100644 --- a/src/components/pages/profile/[name]/registration/steps/Transactions.tsx +++ b/src/components/pages/profile/[name]/registration/steps/Transactions.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { useAccount } from 'wagmi' +import { makeCommitment } from '@ensdomains/ensjs/utils' import { Button, CountdownCircle, @@ -15,6 +16,7 @@ import { import MobileFullWidth from '@app/components/@atoms/MobileFullWidth' import { Card } from '@app/components/Card' +import { useExistingCommitment } from '@app/hooks/registration/useExistingCommitment' import useRegistrationParams from '@app/hooks/useRegistrationParams' import { CenteredTypography } from '@app/transaction-flow/input/ProfileEditor/components/CenteredTypography' import { createTransactionItem } from '@app/transaction-flow/transaction' @@ -120,6 +122,14 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { registrationData, }) + const commitCouldBeFound = + !commitTx?.stage || commitTx.stage === 'confirm' || commitTx.stage === 'failed' + useExistingCommitment({ + commitment: makeCommitment(registrationParams), + enabled: commitCouldBeFound, + commitKey, + }) + const makeCommitNameFlow = useCallback(() => { onStart() createTransactionFlow(commitKey, { diff --git a/src/hooks/registration/useExistingCommitment.ts b/src/hooks/registration/useExistingCommitment.ts new file mode 100644 index 000000000..5af4e5f8a --- /dev/null +++ b/src/hooks/registration/useExistingCommitment.ts @@ -0,0 +1,294 @@ +import { QueryFunctionContext, useQuery } from '@tanstack/react-query' +import { + decodeFunctionData, + encodeFunctionData, + getAddress, + Hash, + Hex, + toFunctionSelector, +} from 'viem' +import { getBlock, getTransactionReceipt, readContract } from 'viem/actions' + +import { + ethRegistrarControllerCommitmentsSnippet, + ethRegistrarControllerCommitSnippet, + getChainContractAddress, +} from '@ensdomains/ensjs/contracts' + +import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { ConfigWithEns, CreateQueryKey, QueryConfig } from '@app/types' +import { getIsCachedData } from '@app/utils/getIsCachedData' +import { prepareQueryOptions } from '@app/utils/prepareQueryOptions' + +import { useInvalidateOnBlock } from '../chain/useInvalidateOnBlock' +import { useAddRecentTransaction } from '../transactions/useAddRecentTransaction' +import { useIsSafeApp } from '../useIsSafeApp' +import { useQueryOptions } from '../useQueryOptions' +import { getBlockMetadataByTimestamp } from './utils/getBlockMetadataByTimestamp' + +type UseExistingCommitmentParameters = { + commitment?: Hex + commitKey?: string +} + +type UseExistingCommitmentInternalParameters = { + setTransactionHashFromUpdate: (key: string, hash: Hash) => void + addRecentTransaction: ReturnType + isSafeTx: boolean +} + +type UseExistingCommitmentReturnType = + | { + status: 'transactionExists' + timestamp: number + } + | { + status: 'commitmentExists' + timestamp: number + } + | { + status: 'commitmentExpired' + timestamp: number + } + | null + +type UseExistingCommitmentConfig = QueryConfig + +type QueryKey = CreateQueryKey< + TParams, + 'getExistingCommitment', + 'standard' +> + +const maxCommitmentAgeSnippet = [ + { + inputs: [], + name: 'maxCommitmentAge', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const + +const getCurrentBlockTimestampSnippet = [ + { + inputs: [], + name: 'getCurrentBlockTimestamp', + outputs: [ + { + internalType: 'uint256', + name: 'timestamp', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const + +const execTransactionSnippet = [ + { + inputs: [ + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + { + internalType: 'enum Enum.Operation', + name: 'operation', + type: 'uint8', + }, + { internalType: 'uint256', name: 'safeTxGas', type: 'uint256' }, + { internalType: 'uint256', name: 'baseGas', type: 'uint256' }, + { internalType: 'uint256', name: 'gasPrice', type: 'uint256' }, + { internalType: 'address', name: 'gasToken', type: 'address' }, + { + internalType: 'address payable', + name: 'refundReceiver', + type: 'address', + }, + { internalType: 'bytes', name: 'signatures', type: 'bytes' }, + ], + name: 'execTransaction', + outputs: [{ internalType: 'bool', name: 'success', type: 'bool' }], + stateMutability: 'payable', + type: 'function', + }, +] as const + +const getExistingCommitmentQueryFn = + (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 ethRegistrarControllerAddress = getChainContractAddress({ + client, + contract: 'ensEthRegistrarController', + }) + const multicall3Address = getChainContractAddress({ + client, + contract: 'multicall3', + }) + + const [commitmentTimestamp, maxCommitmentAge, blockTimestamp] = await Promise.all([ + readContract(client, { + abi: ethRegistrarControllerCommitmentsSnippet, + address: ethRegistrarControllerAddress, + functionName: 'commitments', + args: [commitment], + }), + readContract(client, { + abi: maxCommitmentAgeSnippet, + address: ethRegistrarControllerAddress, + 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: ethRegistrarControllerCommitSnippet, + args: [commitment], + functionName: 'commit', + }) + + const transaction = (() => { + const checksummedAddress = getAddress(address) + const checksummedEthRegistrarControllerAddress = getAddress(ethRegistrarControllerAddress) + 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 + } + +export const useExistingCommitment = ({ + // config + enabled = true, + gcTime, + staleTime, + scopeKey, + // params + ...params +}: TParams & UseExistingCommitmentConfig) => { + const initialOptions = useQueryOptions({ + params, + scopeKey, + functionName: 'getExistingCommitment', + queryDependencyType: 'standard', + queryFn: getExistingCommitmentQueryFn, + }) + + const addRecentTransaction = useAddRecentTransaction() + const { setTransactionHashFromUpdate } = useTransactionFlow() + const { data: isSafeApp, isLoading: isSafeAppLoading } = useIsSafeApp() + + if (process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_ETH_NODE === 'anvil') + console.log('commit is:', params.commitment) + + const preparedOptions = prepareQueryOptions({ + queryKey: initialOptions.queryKey, + queryFn: initialOptions.queryFn({ + addRecentTransaction, + setTransactionHashFromUpdate, + isSafeTx: !!isSafeApp, + }), + enabled: enabled && !!params.commitment && !isSafeAppLoading, + gcTime, + staleTime, + }) + + useInvalidateOnBlock({ + enabled: preparedOptions.enabled, + queryKey: preparedOptions.queryKey, + }) + + const query = useQuery(preparedOptions) + + return { + ...query, + isCachedData: getIsCachedData(query), + } +} diff --git a/src/hooks/registration/utils/getBlockMetadataByTimestamp.ts b/src/hooks/registration/utils/getBlockMetadataByTimestamp.ts new file mode 100644 index 000000000..714b24a2e --- /dev/null +++ b/src/hooks/registration/utils/getBlockMetadataByTimestamp.ts @@ -0,0 +1,47 @@ +import { Hash } from 'viem' + +import { ClientWithEns } from '@app/types' + +type GetBlockMetadataByTimestampParameters = { + timestamp: bigint +} + +type FoundBlockReturnType = { + hash: Hash + number: number + timestamp: number + parentHash: Hash +} + +type BlockErrorReturnType = { + error: string +} + +type GetBlockMetadataByTimestampReturnType = + | { + ok: true + data: FoundBlockReturnType + } + | { + ok: false + data: BlockErrorReturnType + } + +export const getBlockMetadataByTimestamp = async ( + client: ClientWithEns, + { timestamp }: GetBlockMetadataByTimestampParameters, +): Promise => { + const data = await fetch( + `https://api.findblock.xyz/v1/chain/${client.chain.id}/block/after/${timestamp}?inclusive=true`, + ).then((res) => res.json()) + if ('error' in data) + return { + ok: false, + data, + } + + return { + ok: true, + data, + } +} diff --git a/src/transaction-flow/TransactionFlowProvider.tsx b/src/transaction-flow/TransactionFlowProvider.tsx index 4ed254989..301ecc0f6 100644 --- a/src/transaction-flow/TransactionFlowProvider.tsx +++ b/src/transaction-flow/TransactionFlowProvider.tsx @@ -7,6 +7,7 @@ import React, { useMemo, useState, } from 'react' +import { Hash } from 'viem' import { useAccountSafely } from '@app/hooks/account/useAccountSafely' import { useLocalStorageReducer } from '@app/hooks/useLocalStorage' @@ -42,6 +43,7 @@ type ProviderValue = { getLatestTransaction: (key: string) => GenericTransaction | undefined stopCurrentFlow: () => void cleanupFlow: (key: string) => void + setTransactionHashFromUpdate: (key: string, hash: Hash) => void } const TransactionContext = React.createContext({ @@ -54,6 +56,7 @@ const TransactionContext = React.createContext({ getLatestTransaction: () => undefined, stopCurrentFlow: () => {}, cleanupFlow: () => {}, + setTransactionHashFromUpdate: () => {}, }) export const TransactionFlowProvider = ({ children }: { children: ReactNode }) => { @@ -147,6 +150,13 @@ export const TransactionFlowProvider = ({ children }: { children: ReactNode }) = [state.items], ) + const setTransactionHashFromUpdate = useCallback( + (key: string, hash: Hash) => { + dispatch({ name: 'setTransactionHashFromUpdate', payload: { key, hash } }) + }, + [dispatch], + ) + const resumeTransactionFlow = useCallback( (key: string) => { dispatch({ name: 'resumeFlowWithCheck', key, payload: { push: router.pushWithHistory } }) @@ -184,6 +194,7 @@ export const TransactionFlowProvider = ({ children }: { children: ReactNode }) = getLatestTransaction, stopCurrentFlow: () => dispatch({ name: 'stopFlow' }), cleanupFlow: (key: string) => dispatch({ name: 'forceCleanupTransaction', payload: key }), + setTransactionHashFromUpdate, } }, [ dispatch, @@ -193,6 +204,7 @@ export const TransactionFlowProvider = ({ children }: { children: ReactNode }) = getLatestTransaction, getTransactionFlowStage, getTransaction, + setTransactionHashFromUpdate, ]) const [selectedKey, setSelectedKey] = useState(null) diff --git a/src/transaction-flow/reducer.ts b/src/transaction-flow/reducer.ts index 88bddd058..e576c012d 100644 --- a/src/transaction-flow/reducer.ts +++ b/src/transaction-flow/reducer.ts @@ -143,6 +143,16 @@ export const reducer = (draft: InternalTransactionFlow, action: TransactionFlowA currentTransaction.sendTime = Date.now() break } + case 'setTransactionHashFromUpdate': { + const { hash, key } = action.payload + const selectedItem = draft.items[key!] + if (!selectedItem) break + const currentTransaction = getCurrentTransaction(selectedItem) || selectedItem.transactions[0] + currentTransaction.hash = hash + currentTransaction.stage = 'sent' + currentTransaction.sendTime = Date.now() + break + } case 'setTransactionStageFromUpdate': { const { hash, key, status, minedData, newHash } = action.payload diff --git a/src/transaction-flow/types.ts b/src/transaction-flow/types.ts index 91e92798d..f292a74bb 100644 --- a/src/transaction-flow/types.ts +++ b/src/transaction-flow/types.ts @@ -124,6 +124,10 @@ export type TransactionFlowAction = name: 'setTransactionHash' payload: Hash } + | { + name: 'setTransactionHashFromUpdate' + payload: { hash: Hash; key: string } + } | { name: 'incrementTransaction' }