diff --git a/e2e/specs/stateless/extendNames.spec.ts b/e2e/specs/stateless/extendNames.spec.ts index b36103402..a4f906b5b 100644 --- a/e2e/specs/stateless/extendNames.spec.ts +++ b/e2e/specs/stateless/extendNames.spec.ts @@ -378,6 +378,10 @@ test('should be able to extend a name by a month', async ({ await test.step('should extend', async () => { await extendNamesModal.getExtendButton.click() const transactionModal = makePageObject('TransactionModal') + + // Verify duration and new expiry display in transaction modal + await expect(page.getByText('1 month')).toBeVisible() + await transactionModal.autoComplete() const newTimestamp = await profilePage.getExpiryTimestamp() @@ -442,6 +446,10 @@ test('should be able to extend a name by a day', async ({ await test.step('should extend', async () => { await extendNamesModal.getExtendButton.click() const transactionModal = makePageObject('TransactionModal') + + // Verify duration and new expiry display in transaction modal + await expect(page.getByText('1 day')).toBeVisible() + await transactionModal.autoComplete() const newTimestamp = await profilePage.getExpiryTimestamp() @@ -516,6 +524,10 @@ test('should be able to extend a name in grace period by a month', async ({ await test.step('should extend', async () => { await extendNamesModal.getExtendButton.click() const transactionModal = makePageObject('TransactionModal') + + // Verify duration and new expiry display in transaction modal + await expect(page.getByText('1 month')).toBeVisible() + await transactionModal.autoComplete() const newTimestamp = await profilePage.getExpiryTimestamp() @@ -592,6 +604,10 @@ test('should be able to extend a name in grace period by 1 day', async ({ await test.step('should extend', async () => { await extendNamesModal.getExtendButton.click() const transactionModal = makePageObject('TransactionModal') + + // Verify duration and new expiry display in transaction modal + await expect(page.getByText('1 day')).toBeVisible() + await transactionModal.autoComplete() const newTimestamp = await profilePage.getExpiryTimestamp() diff --git a/src/components/@molecules/TransactionDialogManager/DisplayItems.tsx b/src/components/@molecules/TransactionDialogManager/DisplayItems.tsx index a33e334ee..0144df1c1 100644 --- a/src/components/@molecules/TransactionDialogManager/DisplayItems.tsx +++ b/src/components/@molecules/TransactionDialogManager/DisplayItems.tsx @@ -9,7 +9,7 @@ import { AvatarWithZorb, NameAvatar } from '@app/components/AvatarWithZorb' import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import { useBeautifiedName } from '@app/hooks/useBeautifiedName' import { TransactionDisplayItem } from '@app/types' -import { formatExpiry, shortenAddress } from '@app/utils/utils' +import { shortenAddress } from '@app/utils/utils' const Container = styled.div( ({ theme }) => css` @@ -231,29 +231,22 @@ const RecordsValue = ({ value }: { value: [string, string | undefined][] }) => { ) } -const DurationValue = ({ value }: { value: string | undefined }) => { +const DurationValue = ({ + value, +}: { + value: { duration: string; newExpiry?: string | undefined } +}) => { const { t } = useTranslation('transactionFlow') if (!value) return null - const regex = /(\d+)\s*years?\s*(?:,?\s*(\d+)?\s*months?)?/ - const matches = value.match(regex) ?? [] - - const years = parseInt(matches.at(1) ?? '0') - const months = parseInt(matches.at(2) ?? '0') - - const date = new Date() - - if (years > 0) date.setFullYear(date.getFullYear() + years) - if (months > 0) date.setMonth(date.getMonth() + months) - return ( - {value} + {value.duration} - {t('transaction.extendNames.newExpiry', { date: formatExpiry(date) })} + {t('transaction.extendNames.newExpiry', { date: value.newExpiry })} ) @@ -261,6 +254,10 @@ const DurationValue = ({ value }: { value: string | undefined }) => { const DisplayItemValue = (props: Omit) => { const { value, type } = props as TransactionDisplayItem + if (type === 'duration') { + return + } + if (type === 'address') { return } @@ -276,9 +273,6 @@ const DisplayItemValue = (props: Omit) => { if (type === 'records') { return } - if (type === 'duration') { - return - } return {value} } diff --git a/src/transaction-flow/transaction/extendNames.test.ts b/src/transaction-flow/transaction/extendNames.test.ts new file mode 100644 index 000000000..7081c2352 --- /dev/null +++ b/src/transaction-flow/transaction/extendNames.test.ts @@ -0,0 +1,224 @@ +import { mockFunction } from '@app/test-utils' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getPrice } from '@ensdomains/ensjs/public' +import { renewNames } from '@ensdomains/ensjs/wallet' + +import { ClientWithEns, ConnectorClientWithEns } from '@app/types' + +import extendNamesTransaction from './extendNames' + +vi.mock('@ensdomains/ensjs/public') +vi.mock('@ensdomains/ensjs/wallet') + +const mockGetPrice = mockFunction(getPrice) +const mockRenewNames = mockFunction(renewNames.makeFunctionData) + +describe('extendNamesTransaction', () => { + const mockClient = {} as ClientWithEns + const mockConnectorClient = {} as ConnectorClientWithEns + + const mockT = vi.fn((key: string, options?: any) => { + if (key === 'transaction.extendNames.actionValue') return 'Extend' + if (key === 'transaction.extendNames.costValue') return `Cost: ${options.value}` + if (key === 'unit.years') return `${options.count} year` + if (key === 'unit.months') return `${options.count} months` + if (key === 'unit.days') return `${options.count} days` + return key + }) + + describe('displayItems', () => { + it('should format display items correctly for a single name', () => { + const JAN_1_2022_TIMESTAMP = 1640995200000 // 2022-01-01T00:00:00.000Z + const ONE_YEAR_SECONDS = 31536000 + + const data = { + names: ['test.eth'], + duration: ONE_YEAR_SECONDS, + startDateTimestamp: JAN_1_2022_TIMESTAMP, + displayPrice: '0.1 ETH', + } + + const result = extendNamesTransaction.displayItems(data, mockT) + + expect(result).toEqual([ + { + label: 'name', + value: 'test.eth', + type: 'name', + }, + { + label: 'action', + value: 'Extend', + }, + { + label: 'duration', + type: 'duration', + value: { + duration: '1 year', + newExpiry: 'January 1, 2023', + }, + }, + { + label: 'cost', + value: 'Cost: 0.1 ETH', + }, + ]) + }) + + it('should format display items correctly for multiple names', () => { + const JAN_1_2022_TIMESTAMP = 1640995200000 // 2022-01-01T00:00:00.000Z + const ONE_YEAR_SECONDS = 31536000 + + const data = { + names: ['test1.eth', 'test2.eth'], + duration: ONE_YEAR_SECONDS, + startDateTimestamp: JAN_1_2022_TIMESTAMP, + displayPrice: '0.2 ETH', + } + + const result = extendNamesTransaction.displayItems(data, mockT) + + expect(result).toEqual([ + { + label: 'name', + value: '2 names', + type: undefined, + }, + { + label: 'action', + value: 'Extend', + }, + { + label: 'duration', + type: 'duration', + value: { + duration: '1 year', + newExpiry: 'January 1, 2023', + }, + }, + { + label: 'cost', + value: 'Cost: 0.2 ETH', + }, + ]) + }) + + it('should handle different duration values', () => { + const JAN_1_2022_TIMESTAMP = 1640995200000 // 2022-01-01T00:00:00.000Z + + const testCases = [ + { + duration: 2592000, + expectedDuration: '30 days', + expectedExpiry: 'January 31, 2022', + }, + { + duration: 15768000, + expectedDuration: '6 months, 1 days', + expectedExpiry: 'July 2, 2022', + }, + { + duration: 31536000, + expectedDuration: '1 year', + expectedExpiry: 'January 1, 2023', + }, + { + duration: 157680000, + expectedDuration: '4 year, 11 months', + expectedExpiry: 'December 31, 2026', + }, + ] + + testCases.forEach(({ duration, expectedDuration, expectedExpiry }) => { + const data = { + names: ['test.eth'], + duration, + startDateTimestamp: JAN_1_2022_TIMESTAMP, + displayPrice: '0.1 ETH', + } + + const result = extendNamesTransaction.displayItems(data, mockT) + expect(result[2]).toEqual({ + label: 'duration', + type: 'duration', + value: { + duration: expectedDuration, + newExpiry: expectedExpiry, + }, + }) + }) + }) + + it('should return display items without newExpiry when startDateTimestamp is not provided', () => { + const data = { + names: ['test.eth'], + duration: 31536000, + displayPrice: '1.02', + } + + const result = extendNamesTransaction.displayItems(data, mockT) + expect(result[2].value.newExpiry).toBeUndefined() + }) + }) + + describe('transaction', () => { + beforeEach(() => { + mockGetPrice.mockReset() + mockRenewNames.mockReset() + }) + + it('should calculate price and create transaction data', async () => { + const data = { + names: ['test.eth'], + duration: 31536000, // 1 year + } + + mockGetPrice.mockResolvedValue({ base: BigInt('1000000000000000000'), premium: 0n }) // 1 ETH + mockRenewNames.mockResolvedValue({ + to: '0x123' as Address, + data: '0x456' as Hex, + value: BigInt('1020000000000000000'), // 1.02 ETH (with 2% buffer) + }) + + const result = await extendNamesTransaction.transaction({ + client: mockClient, + connectorClient: mockConnectorClient, + data, + }) + + expect(mockGetPrice).toHaveBeenCalledWith(mockClient, { + nameOrNames: ['test.eth'], + duration: 31536000, + }) + expect(mockRenewNames).toHaveBeenCalledWith(mockConnectorClient, { + nameOrNames: ['test.eth'], + duration: 31536000, + value: BigInt('1020000000000000000'), + }) + expect(result).toEqual({ + to: '0x123', + data: '0x456', + value: BigInt('1020000000000000000'), + }) + }) + + it('should throw error when price is not found', async () => { + const data = { + names: ['test.eth'], + duration: 31536000, + } + + mockGetPrice.mockResolvedValue(null) + + await expect( + extendNamesTransaction.transaction({ + client: mockClient, + connectorClient: mockConnectorClient, + data, + }), + ).rejects.toThrow('No price found') + }) + }) +}) diff --git a/src/transaction-flow/transaction/extendNames.ts b/src/transaction-flow/transaction/extendNames.ts index 786cbd062..550554472 100644 --- a/src/transaction-flow/transaction/extendNames.ts +++ b/src/transaction-flow/transaction/extendNames.ts @@ -5,7 +5,7 @@ import { renewNames } from '@ensdomains/ensjs/wallet' import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' -import { calculateValueWithBuffer, formatDurationOfDates } from '../../utils/utils' +import { calculateValueWithBuffer, formatDurationOfDates, formatExpiry } from '../../utils/utils' type Data = { names: string[] @@ -31,11 +31,16 @@ const displayItems = ( { type: 'duration', label: 'duration', - value: formatDurationOfDates({ - startDate: startDateTimestamp ? new Date(startDateTimestamp) : undefined, - endDate: startDateTimestamp ? new Date(startDateTimestamp + duration * 1000) : undefined, - t, - }), + value: { + duration: formatDurationOfDates({ + startDate: startDateTimestamp ? new Date(startDateTimestamp) : undefined, + endDate: startDateTimestamp ? new Date(startDateTimestamp + duration * 1000) : undefined, + t, + }), + newExpiry: startDateTimestamp + ? formatExpiry(new Date(startDateTimestamp + duration * 1000)) + : undefined, + }, }, { label: 'cost', diff --git a/src/types/index.ts b/src/types/index.ts index ae8dd814d..63dee4de2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -36,10 +36,18 @@ interface TransactionDisplayItemBase { useRawLabel?: boolean } -export interface TransactionDisplayItemSingle extends TransactionDisplayItemBase { - type?: 'name' | 'subname' | 'address' | 'duration' | undefined - value: string -} +export type TransactionDisplayItemSingle = + | (TransactionDisplayItemBase & { + type?: 'name' | 'subname' | 'address' | undefined + value: string + }) + | (TransactionDisplayItemBase & { + type: 'duration' + value: { + duration: string + newExpiry?: string + } + }) export interface TransactionDisplayItemList extends TransactionDisplayItemBase { type: 'list'