diff --git a/apps/web/src/components/Basenames/AcceptOwnershipBanner/index.test.tsx b/apps/web/src/components/Basenames/AcceptOwnershipBanner/index.test.tsx new file mode 100644 index 0000000000..37bdbbaa46 --- /dev/null +++ b/apps/web/src/components/Basenames/AcceptOwnershipBanner/index.test.tsx @@ -0,0 +1,208 @@ +/** + * @jest-environment jsdom + */ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import AcceptOwnershipBanner from './index'; +import { WriteTransactionWithReceiptStatus } from 'apps/web/src/hooks/useAcceptOwnership'; + +// Mock the hooks +const mockUsePendingOwnerStatus = jest.fn(); +const mockUseAcceptOwnership = jest.fn(); + +jest.mock('apps/web/src/hooks/usePendingOwnerStatus', () => ({ + usePendingOwnerStatus: () => mockUsePendingOwnerStatus(), +})); + +jest.mock('apps/web/src/hooks/useAcceptOwnership', () => ({ + useAcceptOwnership: () => mockUseAcceptOwnership(), + WriteTransactionWithReceiptStatus: { + Idle: 'idle', + Initiated: 'initiated', + Canceled: 'canceled', + Approved: 'approved', + Processing: 'processing', + Reverted: 'reverted', + Success: 'success', + }, +})); + +// Mock WalletIdentity component +jest.mock('apps/web/src/components/WalletIdentity', () => { + return function MockWalletIdentity({ address }: { address: string }) { + return
{address}
; + }; +}); + +describe('AcceptOwnershipBanner', () => { + const mockCurrentOwner = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as const; + const mockAcceptOwnership = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUsePendingOwnerStatus.mockReturnValue({ + isPendingOwner: false, + currentOwner: mockCurrentOwner, + isLoading: false, + }); + + mockUseAcceptOwnership.mockReturnValue({ + acceptOwnership: mockAcceptOwnership, + transactionStatus: WriteTransactionWithReceiptStatus.Idle, + transactionIsLoading: false, + transactionIsSuccess: false, + transactionIsError: false, + transactionError: null, + }); + }); + + it('should not render when user is not pending owner', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should not render when loading', () => { + mockUsePendingOwnerStatus.mockReturnValue({ + isPendingOwner: false, + currentOwner: mockCurrentOwner, + isLoading: true, + }); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should render banner when user is pending owner', () => { + mockUsePendingOwnerStatus.mockReturnValue({ + isPendingOwner: true, + currentOwner: mockCurrentOwner, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Pending Ownership Transfer')).toBeInTheDocument(); + expect( + screen.getByText(/You have been designated as the pending owner/i), + ).toBeInTheDocument(); + expect(screen.getByText('Accept Ownership')).toBeInTheDocument(); + }); + + it('should display current owner address', () => { + mockUsePendingOwnerStatus.mockReturnValue({ + isPendingOwner: true, + currentOwner: mockCurrentOwner, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Current owner:')).toBeInTheDocument(); + expect(screen.getByTestId('wallet-identity')).toHaveTextContent(mockCurrentOwner); + }); + + it('should call acceptOwnership when button is clicked', async () => { + mockUsePendingOwnerStatus.mockReturnValue({ + isPendingOwner: true, + currentOwner: mockCurrentOwner, + isLoading: false, + }); + + mockAcceptOwnership.mockResolvedValue(undefined); + + render(); + + const button = screen.getByText('Accept Ownership'); + fireEvent.click(button); + + await waitFor(() => { + expect(mockAcceptOwnership).toHaveBeenCalledTimes(1); + }); + }); + + it('should disable button when transaction is processing', () => { + mockUsePendingOwnerStatus.mockReturnValue({ + isPendingOwner: true, + currentOwner: mockCurrentOwner, + isLoading: false, + }); + + mockUseAcceptOwnership.mockReturnValue({ + acceptOwnership: mockAcceptOwnership, + transactionStatus: WriteTransactionWithReceiptStatus.Processing, + transactionIsLoading: true, + transactionIsSuccess: false, + transactionIsError: false, + transactionError: null, + }); + + render(); + + const button = screen.getByText('Processing...'); + expect(button).toBeDisabled(); + }); + + it('should display error message when transaction fails', () => { + const mockError = new Error('Transaction failed'); + mockUsePendingOwnerStatus.mockReturnValue({ + isPendingOwner: true, + currentOwner: mockCurrentOwner, + isLoading: false, + }); + + mockUseAcceptOwnership.mockReturnValue({ + acceptOwnership: mockAcceptOwnership, + transactionStatus: WriteTransactionWithReceiptStatus.Idle, + transactionIsLoading: false, + transactionIsSuccess: false, + transactionIsError: true, + transactionError: mockError, + }); + + render(); + + expect(screen.getByText('Error accepting ownership')).toBeInTheDocument(); + expect(screen.getByText('Transaction failed')).toBeInTheDocument(); + }); + + it('should not render after successful transaction', () => { + mockUsePendingOwnerStatus.mockReturnValue({ + isPendingOwner: true, + currentOwner: mockCurrentOwner, + isLoading: false, + }); + + mockUseAcceptOwnership.mockReturnValue({ + acceptOwnership: mockAcceptOwnership, + transactionStatus: WriteTransactionWithReceiptStatus.Success, + transactionIsLoading: false, + transactionIsSuccess: true, + transactionIsError: false, + transactionError: null, + }); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should show transaction status', () => { + mockUsePendingOwnerStatus.mockReturnValue({ + isPendingOwner: true, + currentOwner: mockCurrentOwner, + isLoading: false, + }); + + mockUseAcceptOwnership.mockReturnValue({ + acceptOwnership: mockAcceptOwnership, + transactionStatus: WriteTransactionWithReceiptStatus.Approved, + transactionIsLoading: false, + transactionIsSuccess: false, + transactionIsError: false, + transactionError: null, + }); + + render(); + + expect(screen.getByText(/Status: approved/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/Basenames/AcceptOwnershipBanner/index.tsx b/apps/web/src/components/Basenames/AcceptOwnershipBanner/index.tsx new file mode 100644 index 0000000000..568aa99658 --- /dev/null +++ b/apps/web/src/components/Basenames/AcceptOwnershipBanner/index.tsx @@ -0,0 +1,94 @@ +'use client'; +import { useCallback } from 'react'; +import { usePendingOwnerStatus } from 'apps/web/src/hooks/usePendingOwnerStatus'; +import { + useAcceptOwnership, + WriteTransactionWithReceiptStatus, +} from 'apps/web/src/hooks/useAcceptOwnership'; +import WalletIdentity from 'apps/web/src/components/WalletIdentity'; + +export default function AcceptOwnershipBanner() { + const { isPendingOwner, currentOwner, isLoading: isPendingOwnerLoading } = usePendingOwnerStatus(); + const { + acceptOwnership, + transactionStatus, + transactionIsLoading, + transactionIsSuccess, + transactionIsError, + transactionError, + } = useAcceptOwnership(); + + const handleAcceptOwnership = useCallback(async () => { + try { + await acceptOwnership(); + } catch (error) { + console.error('Failed to accept ownership:', error); + } + }, [acceptOwnership]); + + // Don't show the banner if the user is not the pending owner + if (isPendingOwnerLoading || !isPendingOwner) { + return null; + } + + // Don't show after successful acceptance + if (transactionIsSuccess) { + return null; + } + + const isProcessing = + transactionStatus === WriteTransactionWithReceiptStatus.Initiated || + transactionStatus === WriteTransactionWithReceiptStatus.Approved || + transactionStatus === WriteTransactionWithReceiptStatus.Processing; + + const canAccept = !transactionIsLoading && !isProcessing; + + return ( +
+
+
+

+ Pending Ownership Transfer +

+

+ You have been designated as the pending owner of the UpgradeableRegistrarController. + Accept ownership to complete the transfer. +

+
+ + {currentOwner && ( +
+ Current owner: + +
+ )} + + {transactionIsError && transactionError && ( +
+

Error accepting ownership

+

{transactionError.message}

+
+ )} + +
+ + + {transactionStatus !== WriteTransactionWithReceiptStatus.Idle && ( + + Status: {transactionStatus} + + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/Basenames/UsernameProfileSettingsOwnership/index.tsx b/apps/web/src/components/Basenames/UsernameProfileSettingsOwnership/index.tsx index cbc131acb1..bd9484e946 100644 --- a/apps/web/src/components/Basenames/UsernameProfileSettingsOwnership/index.tsx +++ b/apps/web/src/components/Basenames/UsernameProfileSettingsOwnership/index.tsx @@ -7,6 +7,7 @@ import Label from 'apps/web/src/components/Label'; import UsernameProfileTransferOwnershipModal from 'apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal'; import ProfileTransferOwnershipProvider from 'apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal/context'; import WalletIdentity from 'apps/web/src/components/WalletIdentity'; +import AcceptOwnershipBanner from 'apps/web/src/components/Basenames/AcceptOwnershipBanner'; const settingTabClass = classNames( 'flex flex-col justify-between gap-8 text-gray/60 md:items-center p-4 md:p-8', @@ -21,6 +22,7 @@ export default function UsernameProfileSettingsOwnership() { return (
+
diff --git a/apps/web/src/hooks/useAcceptOwnership.test.ts b/apps/web/src/hooks/useAcceptOwnership.test.ts new file mode 100644 index 0000000000..490cb54b41 --- /dev/null +++ b/apps/web/src/hooks/useAcceptOwnership.test.ts @@ -0,0 +1,141 @@ +/** + * @jest-environment jsdom + */ +import { renderHook, waitFor } from '@testing-library/react'; +import { useAcceptOwnership, WriteTransactionWithReceiptStatus } from './useAcceptOwnership'; +import { base } from 'viem/chains'; + +// Mock useWriteContractWithReceipt +const mockInitiateTransaction = jest.fn(); +const mockUseWriteContractWithReceipt = jest.fn(); + +jest.mock('apps/web/src/hooks/useWriteContractWithReceipt', () => ({ + __esModule: true, + default: () => mockUseWriteContractWithReceipt(), + WriteTransactionWithReceiptStatus: { + Idle: 'idle', + Initiated: 'initiated', + Canceled: 'canceled', + Approved: 'approved', + Processing: 'processing', + Reverted: 'reverted', + Success: 'success', + }, +})); + +// Mock useBasenameChain +jest.mock('apps/web/src/hooks/useBasenameChain', () => ({ + __esModule: true, + default: () => ({ basenameChain: base }), +})); + +describe('useAcceptOwnership', () => { + const mockTransactionHash = '0xabc123' as `0x${string}`; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseWriteContractWithReceipt.mockReturnValue({ + initiateTransaction: mockInitiateTransaction, + transactionHash: undefined, + transactionStatus: WriteTransactionWithReceiptStatus.Idle, + transactionReceipt: undefined, + transactionIsLoading: false, + transactionIsSuccess: false, + transactionIsError: false, + transactionError: null, + }); + }); + + it('should initialize with idle state', () => { + const { result } = renderHook(() => useAcceptOwnership()); + + expect(result.current.transactionStatus).toBe(WriteTransactionWithReceiptStatus.Idle); + expect(result.current.transactionIsLoading).toBe(false); + expect(result.current.transactionIsSuccess).toBe(false); + expect(result.current.transactionIsError).toBe(false); + }); + + it('should call acceptOwnership with correct contract parameters', async () => { + mockInitiateTransaction.mockResolvedValue(undefined); + + const { result } = renderHook(() => useAcceptOwnership()); + + await result.current.acceptOwnership(); + + expect(mockInitiateTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + abi: expect.any(Array), + functionName: 'acceptOwnership', + args: [], + }), + ); + }); + + it('should handle transaction success', async () => { + mockUseWriteContractWithReceipt.mockReturnValue({ + initiateTransaction: mockInitiateTransaction, + transactionHash: mockTransactionHash, + transactionStatus: WriteTransactionWithReceiptStatus.Success, + transactionReceipt: { status: 'success' }, + transactionIsLoading: false, + transactionIsSuccess: true, + transactionIsError: false, + transactionError: null, + }); + + const { result } = renderHook(() => useAcceptOwnership()); + + expect(result.current.transactionIsSuccess).toBe(true); + expect(result.current.transactionHash).toBe(mockTransactionHash); + }); + + it('should handle transaction error', async () => { + const mockError = new Error('Transaction failed'); + mockUseWriteContractWithReceipt.mockReturnValue({ + initiateTransaction: mockInitiateTransaction, + transactionHash: undefined, + transactionStatus: WriteTransactionWithReceiptStatus.Idle, + transactionReceipt: undefined, + transactionIsLoading: false, + transactionIsSuccess: false, + transactionIsError: true, + transactionError: mockError, + }); + + const { result } = renderHook(() => useAcceptOwnership()); + + expect(result.current.transactionIsError).toBe(true); + expect(result.current.transactionError).toBe(mockError); + }); + + it('should handle loading state', async () => { + mockUseWriteContractWithReceipt.mockReturnValue({ + initiateTransaction: mockInitiateTransaction, + transactionHash: mockTransactionHash, + transactionStatus: WriteTransactionWithReceiptStatus.Processing, + transactionReceipt: undefined, + transactionIsLoading: true, + transactionIsSuccess: false, + transactionIsError: false, + transactionError: null, + }); + + const { result } = renderHook(() => useAcceptOwnership()); + + expect(result.current.transactionIsLoading).toBe(true); + expect(result.current.transactionStatus).toBe(WriteTransactionWithReceiptStatus.Processing); + }); + + it('should handle error when contract address is not found', async () => { + // When acceptOwnership is called with an invalid chain, it should throw + mockInitiateTransaction.mockRejectedValue( + new Error('Contract address not found for chain 99999') + ); + + const { result } = renderHook(() => useAcceptOwnership()); + + await expect(result.current.acceptOwnership()).rejects.toThrow( + 'Contract address not found for chain 99999' + ); + }); +}); diff --git a/apps/web/src/hooks/useAcceptOwnership.ts b/apps/web/src/hooks/useAcceptOwnership.ts new file mode 100644 index 0000000000..8908ddd9da --- /dev/null +++ b/apps/web/src/hooks/useAcceptOwnership.ts @@ -0,0 +1,60 @@ +import { useCallback } from 'react'; +import { ContractFunctionParameters } from 'viem'; +import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; +import useWriteContractWithReceipt, { + WriteTransactionWithReceiptStatus, +} from 'apps/web/src/hooks/useWriteContractWithReceipt'; +import UpgradeableRegistrarControllerAbi from 'apps/web/src/abis/UpgradeableRegistrarControllerAbi'; +import { UPGRADEABLE_REGISTRAR_CONTROLLER_ADDRESSES } from 'apps/web/src/addresses/usernames'; + +/** + * Hook to accept ownership of the UpgradeableRegistrarController contract + * This is the second step in a two-step ownership transfer process + */ +export function useAcceptOwnership() { + const { basenameChain } = useBasenameChain(); + + const { + initiateTransaction, + transactionHash, + transactionStatus, + transactionReceipt, + transactionIsLoading, + transactionIsSuccess, + transactionIsError, + transactionError, + } = useWriteContractWithReceipt({ + chain: basenameChain, + eventName: 'basename_accept_ownership', + }); + + const acceptOwnership = useCallback(async () => { + const contractAddress = UPGRADEABLE_REGISTRAR_CONTROLLER_ADDRESSES[basenameChain.id]; + + if (!contractAddress) { + throw new Error(`Contract address not found for chain ${basenameChain.id}`); + } + + const contractParameters: ContractFunctionParameters = { + address: contractAddress, + abi: UpgradeableRegistrarControllerAbi, + functionName: 'acceptOwnership', + args: [], + }; + + await initiateTransaction(contractParameters); + }, [basenameChain.id, initiateTransaction]); + + return { + acceptOwnership, + transactionHash, + transactionStatus, + transactionReceipt, + transactionIsLoading, + transactionIsSuccess, + transactionIsError, + transactionError, + }; +} + +export { WriteTransactionWithReceiptStatus }; diff --git a/apps/web/src/hooks/usePendingOwnerStatus.test.ts b/apps/web/src/hooks/usePendingOwnerStatus.test.ts new file mode 100644 index 0000000000..cc3a3c3db4 --- /dev/null +++ b/apps/web/src/hooks/usePendingOwnerStatus.test.ts @@ -0,0 +1,120 @@ +/** + * @jest-environment jsdom + */ +import { renderHook } from '@testing-library/react'; +import { usePendingOwnerStatus } from './usePendingOwnerStatus'; +import { base, baseSepolia } from 'viem/chains'; + +// Mock wagmi hooks +const mockUseAccount = jest.fn(); +const mockUseReadContract = jest.fn(); + +jest.mock('wagmi', () => ({ + useAccount: () => mockUseAccount(), + useReadContract: (args: unknown) => mockUseReadContract(args), +})); + +// Mock useBasenameChain +jest.mock('apps/web/src/hooks/useBasenameChain', () => ({ + __esModule: true, + default: () => ({ basenameChain: base }), +})); + +describe('usePendingOwnerStatus', () => { + const mockAddress = '0x1234567890123456789012345678901234567890' as const; + const mockPendingOwner = '0x1234567890123456789012345678901234567890' as const; + const mockCurrentOwner = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as const; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseAccount.mockReturnValue({ address: mockAddress }); + }); + + it('should return isPendingOwner as true when address matches pendingOwner', () => { + let callCount = 0; + mockUseReadContract.mockImplementation(() => { + callCount++; + // First call is for pendingOwner, second is for owner + if (callCount === 1) { + return { data: mockPendingOwner, isLoading: false }; + } + return { data: mockCurrentOwner, isLoading: false }; + }); + + const { result } = renderHook(() => usePendingOwnerStatus()); + + expect(result.current.isPendingOwner).toBe(true); + expect(result.current.pendingOwner).toBe(mockPendingOwner); + expect(result.current.currentOwner).toBe(mockCurrentOwner); + expect(result.current.isLoading).toBe(false); + }); + + it('should return isPendingOwner as false when address does not match pendingOwner', () => { + const differentAddress = '0xdifferent1234567890123456789012345678901234' as const; + mockUseAccount.mockReturnValue({ address: mockAddress }); + + let callCount = 0; + mockUseReadContract.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return { data: differentAddress, isLoading: false }; + } + return { data: mockCurrentOwner, isLoading: false }; + }); + + const { result } = renderHook(() => usePendingOwnerStatus()); + + expect(result.current.isPendingOwner).toBe(false); + expect(result.current.pendingOwner).toBe(differentAddress); + }); + + it('should return isPendingOwner as false when there is no pending owner', () => { + let callCount = 0; + mockUseReadContract.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return { data: undefined, isLoading: false }; + } + return { data: mockCurrentOwner, isLoading: false }; + }); + + const { result } = renderHook(() => usePendingOwnerStatus()); + + expect(result.current.isPendingOwner).toBe(false); + expect(result.current.pendingOwner).toBeUndefined(); + }); + + it('should handle loading state correctly', () => { + mockUseReadContract.mockReturnValue({ data: undefined, isLoading: true }); + + const { result } = renderHook(() => usePendingOwnerStatus()); + + expect(result.current.isLoading).toBe(true); + }); + + it('should return isPendingOwner as false when user is not connected', () => { + mockUseAccount.mockReturnValue({ address: undefined }); + mockUseReadContract.mockReturnValue({ data: mockPendingOwner, isLoading: false }); + + const { result } = renderHook(() => usePendingOwnerStatus()); + + expect(result.current.isPendingOwner).toBe(false); + }); + + it('should compare addresses case-insensitively', () => { + const upperCasePendingOwner = mockPendingOwner.toUpperCase() as `0x${string}`; + + let callCount = 0; + mockUseReadContract.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return { data: upperCasePendingOwner, isLoading: false }; + } + return { data: mockCurrentOwner, isLoading: false }; + }); + + const { result } = renderHook(() => usePendingOwnerStatus()); + + expect(result.current.isPendingOwner).toBe(true); + }); +}); diff --git a/apps/web/src/hooks/usePendingOwnerStatus.ts b/apps/web/src/hooks/usePendingOwnerStatus.ts new file mode 100644 index 0000000000..d5707fd961 --- /dev/null +++ b/apps/web/src/hooks/usePendingOwnerStatus.ts @@ -0,0 +1,55 @@ +import useBasenameChain from 'apps/web/src/hooks/useBasenameChain'; +import UpgradeableRegistrarControllerAbi from 'apps/web/src/abis/UpgradeableRegistrarControllerAbi'; +import { UPGRADEABLE_REGISTRAR_CONTROLLER_ADDRESSES } from 'apps/web/src/addresses/usernames'; +import { useReadContract } from 'wagmi'; +import { useAccount } from 'wagmi'; +import { useMemo } from 'react'; + +/** + * Hook to check if the current wallet address is the pending owner + * of the UpgradeableRegistrarController contract + */ +export function usePendingOwnerStatus() { + const { address } = useAccount(); + const { basenameChain } = useBasenameChain(); + + const contractAddress = UPGRADEABLE_REGISTRAR_CONTROLLER_ADDRESSES[basenameChain.id]; + + // Read the pendingOwner from the contract + const { data: pendingOwner, isLoading: isPendingOwnerLoading } = useReadContract({ + address: contractAddress, + abi: UpgradeableRegistrarControllerAbi, + functionName: 'pendingOwner', + chainId: basenameChain.id, + query: { + enabled: !!contractAddress && !!address, + }, + }); + + // Read the current owner from the contract + // Note: This is returned for display purposes in the UI (e.g., showing who the current owner is) + const { data: currentOwner, isLoading: isOwnerLoading } = useReadContract({ + address: contractAddress, + abi: UpgradeableRegistrarControllerAbi, + functionName: 'owner', + chainId: basenameChain.id, + query: { + enabled: !!contractAddress && !!address, + }, + }); + + // Determine if the current user is the pending owner + const isPendingOwner = useMemo(() => { + if (!address || !pendingOwner) return false; + return pendingOwner.toLowerCase() === address.toLowerCase(); + }, [address, pendingOwner]); + + const isLoading = isPendingOwnerLoading || isOwnerLoading; + + return { + isPendingOwner, + pendingOwner, + currentOwner, + isLoading, + }; +}