diff --git a/public/locales/en/transactionFlow.json b/public/locales/en/transactionFlow.json index 673233580..1bb68da41 100644 --- a/public/locales/en/transactionFlow.json +++ b/public/locales/en/transactionFlow.json @@ -338,7 +338,11 @@ "invalidResolver": { "title": "Unauthorized resolver set", "description": "To use this as your primary name you will need to set a valid resolver and update the ETH address first." - } + } + }, + "multiStepSubnameDelete": { + "title": "Delete Subname", + "description": "Deleting this subname requires multiple transactions" } }, "errors": { diff --git a/src/hooks/useProfileActions.test.ts b/src/hooks/useProfileActions.test.ts index 5bf167df2..24c8bf617 100644 --- a/src/hooks/useProfileActions.test.ts +++ b/src/hooks/useProfileActions.test.ts @@ -7,6 +7,7 @@ import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvide import { useHasGlobalError } from './errors/useHasGlobalError' import { useProfileActions } from './useProfileActions' +import useWrapperApprovedForAll from './useWrapperApprovedForAll' const NOW_TIMESTAMP = 1588994800000 jest.spyOn(Date, 'now').mockImplementation(() => NOW_TIMESTAMP) @@ -23,10 +24,12 @@ jest.mock('@app/hooks/resolver/useResolverStatus', () => ({ jest.mock('@app/hooks/usePrimary') jest.mock('@app/transaction-flow/TransactionFlowProvider') jest.mock('./errors/useHasGlobalError') +jest.mock('./useWrapperApprovedForAll') const mockUsePrimary = mockFunction(usePrimary) const mockUseTransactionFlow = mockFunction(useTransactionFlow) const mockUseHasGlobalError = mockFunction(useHasGlobalError) +const mockUseWrapperApprovedForAll = mockFunction(useWrapperApprovedForAll) const mockCreateTransactionFlow = jest.fn() const mockPrepareDataInput = jest.fn() @@ -76,6 +79,7 @@ describe('useProfileActions', () => { createTransactionFlow: (...args: any[]) => mockCreateTransactionFlow(...args), }) mockUseHasGlobalError.mockReturnValue(false) + mockUseWrapperApprovedForAll.mockReturnValue({ approvedForAll: true, isLoading: false }) }) afterEach(() => { @@ -142,6 +146,121 @@ describe('useProfileActions', () => { ) }) + describe('delete subname', () => { + it('should return a single transaction with normal subname', () => { + const { result } = renderHook(() => useProfileActions(props)) + const deleteAction = result.current.profileActions?.find( + (a) => a.label === 'tabs.profile.actions.deleteSubname.label', + ) + deleteAction!.onClick() + expect(mockCreateTransactionFlow).toHaveBeenCalledWith('deleteSubname-test.eth', { + transactions: [ + { + name: 'deleteSubname', + data: { + name: 'test.eth', + contract: 'testcontract', + method: 'testmethod', + }, + }, + ], + }) + }) + it('should return a two step transaction flow for an unwrapped subname with wrapped parent', () => { + const { result } = renderHook(() => + useProfileActions({ + ...props, + subnameAbilities: { + ...props.subnameAbilities, + canDeleteRequiresWrap: true, + }, + }), + ) + const deleteAction = result.current.profileActions?.find( + (a) => a.label === 'tabs.profile.actions.deleteSubname.label', + ) + deleteAction!.onClick() + expect(mockCreateTransactionFlow).toHaveBeenCalledWith('deleteSubname-test.eth', { + transactions: [ + { + name: 'wrapName', + data: { + name: 'test.eth', + }, + }, + { + name: 'deleteSubname', + data: { + contract: 'nameWrapper', + name: 'test.eth', + method: 'setRecord', + }, + }, + ], + resumable: true, + intro: { + title: ['intro.multiStepSubnameDelete.title', { ns: 'transactionFlow' }], + content: { + name: 'GenericWithDescription', + data: { + description: 'intro.multiStepSubnameDelete.description', + }, + }, + }, + }) + }) + it('should return a three step transaction flow for an unwrapped subname with wrapped parent with namewrapper not approved by address', () => { + mockUseWrapperApprovedForAll.mockReturnValue({ approvedForAll: false, isLoading: false }) + const { result } = renderHook(() => + useProfileActions({ + ...props, + subnameAbilities: { + ...props.subnameAbilities, + canDeleteRequiresWrap: true, + }, + }), + ) + const deleteAction = result.current.profileActions?.find( + (a) => a.label === 'tabs.profile.actions.deleteSubname.label', + ) + deleteAction!.onClick() + expect(mockCreateTransactionFlow).toHaveBeenCalledWith('deleteSubname-test.eth', { + transactions: [ + { + name: 'approveNameWrapper', + data: { + address: '0x1234567890', + }, + }, + { + name: 'wrapName', + data: { + name: 'test.eth', + }, + }, + { + name: 'deleteSubname', + data: { + contract: 'nameWrapper', + name: 'test.eth', + method: 'setRecord', + }, + }, + ], + resumable: true, + intro: { + title: ['intro.multiStepSubnameDelete.title', { ns: 'transactionFlow' }], + content: { + name: 'GenericWithDescription', + data: { + description: 'intro.multiStepSubnameDelete.description', + }, + }, + }, + }) + }) + }) + describe('set primary name', () => { it('should return an action for a single transaction with base mock data', async () => { const { result } = renderHook(() => useProfileActions(props)) diff --git a/src/hooks/useProfileActions.ts b/src/hooks/useProfileActions.ts index d81485764..d80807cd8 100644 --- a/src/hooks/useProfileActions.ts +++ b/src/hooks/useProfileActions.ts @@ -5,7 +5,9 @@ import { checkIsDecrypted } from '@ensdomains/ensjs/utils/labels' import { usePrimary } from '@app/hooks/usePrimary' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { makeIntroItem } from '@app/transaction-flow/intro' import { makeTransactionItem } from '@app/transaction-flow/transaction' +import { GenericTransaction } from '@app/transaction-flow/types' import { ReturnedENS } from '@app/types' import { nameParts } from '@app/utils/name' @@ -16,6 +18,7 @@ import { useResolverStatus } from './resolver/useResolverStatus' import { useNameDetails } from './useNameDetails' import { useSelfAbilities } from './useSelfAbilities' import { useSubnameAbilities } from './useSubnameAbilities' +import useWrapperApprovedForAll from './useWrapperApprovedForAll' type Action = { onClick: () => void @@ -62,6 +65,12 @@ export const useProfileActions = ({ const primary = usePrimary(address) + const wrappedApproved = useWrapperApprovedForAll( + address || '', + subnameAbilities.canDelete, + !!subnameAbilities.canDeleteRequiresWrap, + ) + const isAvailablePrimaryName = checkAvailablePrimaryName( primary.data?.name, resolverStatus.data, @@ -92,7 +101,10 @@ export const useProfileActions = ({ ) const isLoading = - primary.isLoading || resolverStatus.isLoading || getPrimaryNameTransactionFlowItem.isLoading + primary.isLoading || + resolverStatus.isLoading || + getPrimaryNameTransactionFlowItem.isLoading || + wrappedApproved.isLoading const profileActions = useMemo(() => { const actions: Action[] = [] @@ -135,40 +147,65 @@ export const useProfileActions = ({ } if (subnameAbilities.canDelete && subnameAbilities.canDeleteContract) { - const action = subnameAbilities.isPCCBurned - ? { - label: t('tabs.profile.actions.deleteSubname.label'), - onClick: () => { - showDeleteEmancipatedSubnameWarningInput( - `delete-emancipated-subname-warning-${name}`, - { name }, - ) - }, - tooltipContent: hasGlobalError - ? t('errors.networkError.blurb', { ns: 'common' }) - : undefined, - red: true, - skip2LDEth: true, - } - : { - label: t('tabs.profile.actions.deleteSubname.label'), - onClick: () => - createTransactionFlow(`deleteSubname-${name}`, { - transactions: [ - makeTransactionItem('deleteSubname', { - name, - contract: subnameAbilities.canDeleteContract!, - method: subnameAbilities.canDeleteMethod, + const base = { + label: t('tabs.profile.actions.deleteSubname.label'), + tooltipContent: hasGlobalError + ? t('errors.networkError.blurb', { ns: 'common' }) + : undefined, + red: true, + skip2LDEth: true, + } + if (subnameAbilities.canDeleteRequiresWrap) { + const transactions: GenericTransaction[] = [ + makeTransactionItem('wrapName', { + name, + }), + makeTransactionItem('deleteSubname', { + contract: 'nameWrapper', + name, + method: 'setRecord', + }), + ] + if (!wrappedApproved.approvedForAll) + transactions.unshift(makeTransactionItem('approveNameWrapper', { address })) + actions.push({ + ...base, + onClick: () => + createTransactionFlow(`deleteSubname-${name}`, { + transactions, + resumable: true, + intro: { + title: ['intro.multiStepSubnameDelete.title', { ns: 'transactionFlow' }], + content: makeIntroItem('GenericWithDescription', { + description: t('intro.multiStepSubnameDelete.description', { + ns: 'transactionFlow', }), - ], - }), - tooltipContent: hasGlobalError - ? t('errors.networkError.blurb', { ns: 'common' }) - : undefined, - red: true, - skip2LDEth: true, - } - actions.push(action) + }), + }, + }), + }) + } else { + actions.push({ + ...base, + onClick: subnameAbilities.isPCCBurned + ? () => { + showDeleteEmancipatedSubnameWarningInput( + `delete-emancipated-subname-warning-${name}`, + { name }, + ) + } + : () => + createTransactionFlow(`deleteSubname-${name}`, { + transactions: [ + makeTransactionItem('deleteSubname', { + name, + contract: subnameAbilities.canDeleteContract!, + method: subnameAbilities.canDeleteMethod, + }), + ], + }), + }) + } } else if (subnameAbilities.canDeleteError) { actions.push({ label: t('tabs.profile.actions.deleteSubname.label'), @@ -204,23 +241,25 @@ export const useProfileActions = ({ return actions }, [ address, + isLoading, + getPrimaryNameTransactionFlowItem, name, + isAvailablePrimaryName, selfAbilities.canEdit, subnameAbilities.canDelete, subnameAbilities.canDeleteContract, subnameAbilities.canDeleteError, - subnameAbilities.canDeleteMethod, - subnameAbilities.isPCCBurned, subnameAbilities.canReclaim, - getPrimaryNameTransactionFlowItem, + subnameAbilities.canDeleteRequiresWrap, + subnameAbilities.isPCCBurned, + subnameAbilities.canDeleteMethod, t, + hasGlobalError, showUnknownLabelsInput, createTransactionFlow, showProfileEditorInput, + wrappedApproved.approvedForAll, showDeleteEmancipatedSubnameWarningInput, - isLoading, - hasGlobalError, - isAvailablePrimaryName, ]) return { diff --git a/src/hooks/useSubnameAbilities.test.tsx b/src/hooks/useSubnameAbilities.test.tsx index 8aae08b07..b80c59f5c 100644 --- a/src/hooks/useSubnameAbilities.test.tsx +++ b/src/hooks/useSubnameAbilities.test.tsx @@ -258,6 +258,24 @@ const groups = [ address: '0xName', abilities: makeResults({ canDelete: false }), }, + { + description: + 'should return canDelete is true and canDeleteRequiresWrap is true if parent is wrapped', + ...unwrappedSubname, + hasSubnames: false, + address: '0xParent', + isParentWrapped: true, + parentOwnerData: makeOwnerData({ + owner: '0xParent', + ownershipLevel: 'nameWrapper', + }), + abilities: makeResults({ + canDelete: true, + canDeleteContract: 'nameWrapper', + canDeleteRequiresWrap: true, + canDeleteError: undefined, + }), + }, ], }, { diff --git a/src/hooks/useSubnameAbilities.ts b/src/hooks/useSubnameAbilities.ts index 0e5b23415..2586faa17 100644 --- a/src/hooks/useSubnameAbilities.ts +++ b/src/hooks/useSubnameAbilities.ts @@ -20,6 +20,7 @@ type DeleteAbilities = { canDeleteContract?: 'registry' | 'nameWrapper' canDeleteMethod?: 'setRecord' | 'setSubnodeOwner' canDeleteError?: string + canDeleteRequiresWrap?: boolean isPCCBurned?: boolean } @@ -38,6 +39,7 @@ type ReturnData = { const getCanDeleteAbilities = ( { isWrapped, + isParentWrapped, isParentOwner, hasSubnames, pccExpired, @@ -47,6 +49,7 @@ const getCanDeleteAbilities = ( nameHasOwner, }: { isWrapped: boolean + isParentWrapped: boolean isParentOwner: boolean hasSubnames: boolean pccExpired: boolean @@ -63,7 +66,15 @@ const getCanDeleteAbilities = ( canDelete: !hasSubnames && !pccExpired, canDeleteContract: 'registry', canDeleteError: hasSubnames ? t('errors.hasSubnames') : undefined, + // if pcc expired, use reclaim process + ...(isParentWrapped && !pccExpired + ? { + canDeleteContract: 'nameWrapper', + canDeleteRequiresWrap: true, + } + : {}), } + if (isWrapped && isPCCBurned && isOwner) { /* eslint-disable no-nested-ternary */ return { @@ -160,6 +171,7 @@ export const useSubnameAbilities = ({ ...getCanDeleteAbilities( { isWrapped, + isParentWrapped, isParentOwner, hasSubnames, pccExpired,