diff --git a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx index 00b518bf..ee53d487 100644 --- a/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/OptimismCrossDomainMessenger/index.tsx @@ -2,7 +2,7 @@ import Icon from '@/src/components/pageComponents/home/Examples/demos/OptimismCr import Wrapper from '@/src/components/pageComponents/home/Examples/wrapper' import Hash from '@/src/components/sharedComponents/Hash' import TransactionButton from '@/src/components/sharedComponents/TransactionButton' -import { withWalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier' +import { WalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier' import { getContract } from '@/src/constants/contracts/contracts' import { useL1CrossDomainMessengerProxy } from '@/src/hooks/useOPL1CrossDomainMessengerProxy' import { useWeb3StatusConnected } from '@/src/hooks/useWeb3Status' @@ -15,27 +15,27 @@ import { parseEther } from 'viem' import { optimismSepolia, sepolia } from 'viem/chains' import { extractTransactionDepositedLogs, getL2TransactionHash } from 'viem/op-stack' -const OptimismCrossDomainMessenger = withWalletStatusVerifier( - withSuspenseAndRetry(() => { - // https://sepolia-optimism.etherscan.io/address/0xb50201558b00496a145fe76f7424749556e326d8 - const AAVEProxy = '0xb50201558b00496a145fe76f7424749556e326d8' - const { address: walletAddress, readOnlyClient } = useWeb3StatusConnected() +const OptimismCrossDomainMessenger = withSuspenseAndRetry(() => { + // https://sepolia-optimism.etherscan.io/address/0xb50201558b00496a145fe76f7424749556e326d8 + const AAVEProxy = '0xb50201558b00496a145fe76f7424749556e326d8' + const { address: walletAddress, readOnlyClient } = useWeb3StatusConnected() - const contract = getContract('AAVEWeth', optimismSepolia.id) - const depositValue = parseEther('0.01') + const contract = getContract('AAVEWeth', optimismSepolia.id) + const depositValue = parseEther('0.01') - const [l2Hash, setL2Hash] = useState
(null) + const [l2Hash, setL2Hash] = useState
(null) - const sendCrossChainMessage = useL1CrossDomainMessengerProxy({ - fromChain: sepolia, - contractName: 'AAVEWeth', - functionName: 'depositETH', - l2ContractAddress: contract.address, - args: [AAVEProxy, walletAddress, 0], - value: depositValue, - }) + const sendCrossChainMessage = useL1CrossDomainMessengerProxy({ + fromChain: sepolia, + contractName: 'AAVEWeth', + functionName: 'depositETH', + l2ContractAddress: contract.address, + args: [AAVEProxy, walletAddress, 0], + value: depositValue, + }) - return ( + return ( +

Deposit 0.01 ETH in{' '} @@ -76,10 +76,9 @@ const OptimismCrossDomainMessenger = withWalletStatusVerifier( )} - ) - }), - { chainId: sepolia.id }, -) + + ) +}) const optimismCrossdomainMessenger = { demo: , diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx index 66fec3ba..58b231b8 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/index.tsx @@ -1,7 +1,7 @@ import BaseERC20ApproveAndTransferButton from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/ERC20ApproveAndTransferButton' import MintUSDC from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/ERC20ApproveAndTransferButton/MintUSDC' import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper' -import { withWalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier' +import { WalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier' import { useSuspenseReadErc20BalanceOf } from '@/src/hooks/generated' import { useWeb3StatusConnected } from '@/src/hooks/useWeb3Status' import type { Token } from '@/src/types/token' @@ -57,59 +57,56 @@ const ABIExample = [ * * Works only on Sepolia chain. */ -const ERC20ApproveAndTransferButton = withWalletStatusVerifier( - withSuspense(() => { - const { address } = useWeb3StatusConnected() - const { writeContractAsync } = useWriteContract() +const ERC20ApproveAndTransferButton = withSuspense(() => { + const { address } = useWeb3StatusConnected() + const { writeContractAsync } = useWriteContract() - const { data: balance, refetch: refetchBalance } = useSuspenseReadErc20BalanceOf({ - address: tokenUSDC_sepolia.address as Address, - args: [address], - }) + const { data: balance, refetch: refetchBalance } = useSuspenseReadErc20BalanceOf({ + address: tokenUSDC_sepolia.address as Address, + args: [address], + }) - // AAVE staging contract pool address - const spender = '0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951' + // AAVE staging contract pool address + const spender = '0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951' - const amount = 10000000000n // 10,000.00 USDC + const amount = 10000000000n // 10,000.00 USDC - const handleTransaction = () => - writeContractAsync({ - abi: ABIExample, - address: spender, - functionName: 'supply', - args: [tokenUSDC_sepolia.address as Address, amount, address, 0], - }) - handleTransaction.methodId = 'Supply USDC' + const handleTransaction = () => + writeContractAsync({ + abi: ABIExample, + address: spender, + functionName: 'supply', + args: [tokenUSDC_sepolia.address as Address, amount, address, 0], + }) + handleTransaction.methodId = 'Supply USDC' - const formattedAmount = formatNumberOrString( - formatUnits(amount, tokenUSDC_sepolia.decimals), - NumberType.TokenTx, - ) + const formattedAmount = formatNumberOrString( + formatUnits(amount, tokenUSDC_sepolia.decimals), + NumberType.TokenTx, + ) - return ( - <> - {balance < amount ? ( - - - - ) : ( - refetchBalance} - spender={spender} - token={tokenUSDC_sepolia} - transaction={handleTransaction} - /> - )} - - ) - }), - { chainId: sepolia.id }, // this DEMO component only works on sepolia chain -) + return ( + + {balance < amount ? ( + + + + ) : ( + refetchBalance()} + spender={spender} + token={tokenUSDC_sepolia} + transaction={handleTransaction} + /> + )} + + ) +}) export default ERC20ApproveAndTransferButton diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx index a1894296..13c32cdd 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx @@ -1,6 +1,6 @@ import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper' import TransactionButton from '@/src/components/sharedComponents/TransactionButton' -import { withWalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier' +import { WalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier' import { GeneralMessage } from '@/src/components/sharedComponents/ui/GeneralMessage' import PrimaryButton from '@/src/components/sharedComponents/ui/PrimaryButton' import { useWeb3StatusConnected } from '@/src/hooks/useWeb3Status' @@ -15,32 +15,32 @@ import { useSendTransaction } from 'wagmi' * * Works only on Sepolia chain. */ -const NativeToken = withWalletStatusVerifier( - () => { - const [isModalOpen, setIsModalOpen] = useState(false) - const { address } = useWeb3StatusConnected() - const { sendTransactionAsync } = useSendTransaction() - const [minedMessage, setMinedMessage] = useState() +const NativeToken = () => { + const [isModalOpen, setIsModalOpen] = useState(false) + const { address } = useWeb3StatusConnected() + const { sendTransactionAsync } = useSendTransaction() + const [minedMessage, setMinedMessage] = useState() - const handleOnMined = (receipt: TransactionReceipt) => { - setMinedMessage( - <> - Hash: {receipt.transactionHash} - , - ) - setIsModalOpen(true) - } + const handleOnMined = (receipt: TransactionReceipt) => { + setMinedMessage( + <> + Hash: {receipt.transactionHash} + , + ) + setIsModalOpen(true) + } - const handleSendTransaction = (): Promise => { - // Send native token - return sendTransactionAsync({ - to: address, - value: parseEther('0.1'), - }) - } - handleSendTransaction.methodId = 'sendTransaction' + const handleSendTransaction = (): Promise => { + // Send native token + return sendTransactionAsync({ + to: address, + value: parseEther('0.1'), + }) + } + handleSendTransaction.methodId = 'sendTransaction' - return ( + return ( + - ) - }, - { - chainId: sepolia.id, // this DEMO component only works on sepolia chain - }, -) + + ) +} export default NativeToken diff --git a/src/components/sharedComponents/SignButton.test.tsx b/src/components/sharedComponents/SignButton.test.tsx new file mode 100644 index 00000000..f4e07cae --- /dev/null +++ b/src/components/sharedComponents/SignButton.test.tsx @@ -0,0 +1,135 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { createElement } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockSwitchChain = vi.fn() +const mockSignMessageAsync = vi.fn() +const mockWatchSignature = vi.fn() + +vi.mock('@/src/hooks/useWalletStatus', () => ({ + useWalletStatus: vi.fn(() => ({ + isReady: false, + needsConnect: true, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' }, + targetChainId: 1, + switchChain: mockSwitchChain, + })), +})) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => + createElement( + 'button', + { type: 'button', 'data-testid': 'connect-wallet-button' }, + 'Connect Wallet', + ), +})) + +vi.mock('@/src/providers/TransactionNotificationProvider', () => ({ + useTransactionNotification: vi.fn(() => ({ + watchSignature: mockWatchSignature, + })), +})) + +vi.mock('wagmi', () => ({ + useSignMessage: vi.fn(() => ({ + isPending: false, + signMessageAsync: mockSignMessageAsync, + })), +})) + +const { useWalletStatus } = await import('@/src/hooks/useWalletStatus') +const mockedUseWalletStatus = vi.mocked(useWalletStatus) + +const system = createSystem(defaultConfig) + +const renderWithChakra = (ui: ReactNode) => + render({ui}) + +describe('SignButton', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders connect button when wallet needs connect', async () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: false, + needsConnect: true, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], + targetChainId: 1, + switchChain: mockSwitchChain, + }) + + const { default: SignButton } = await import('./SignButton') + + renderWithChakra() + + expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument() + expect(screen.queryByText('Sign Message')).toBeNull() + }) + + it('renders custom fallback when provided and wallet needs connect', async () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: false, + needsConnect: true, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], + targetChainId: 1, + switchChain: mockSwitchChain, + }) + + const { default: SignButton } = await import('./SignButton') + + renderWithChakra( + , + ) + + expect(screen.getByTestId('custom-fallback')).toBeInTheDocument() + expect(screen.queryByText('Sign Message')).toBeNull() + }) + + it('renders switch chain button when wallet needs chain switch', async () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: false, + needsConnect: false, + needsChainSwitch: true, + targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType< + typeof useWalletStatus + >['targetChain'], + targetChainId: 10, + switchChain: mockSwitchChain, + }) + + const { default: SignButton } = await import('./SignButton') + + renderWithChakra() + + expect(screen.getByText(/Switch to/)).toBeInTheDocument() + expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument() + expect(screen.queryByText('Sign Message')).toBeNull() + }) + + it('renders sign button when wallet is ready', async () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: true, + needsConnect: false, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], + targetChainId: 1, + switchChain: mockSwitchChain, + }) + + const { default: SignButton } = await import('./SignButton') + + renderWithChakra() + + expect(screen.getByText('Sign Message')).toBeInTheDocument() + }) +}) diff --git a/src/components/sharedComponents/SignButton.tsx b/src/components/sharedComponents/SignButton.tsx index d548ab4a..5abcdd4c 100644 --- a/src/components/sharedComponents/SignButton.tsx +++ b/src/components/sharedComponents/SignButton.tsx @@ -1,29 +1,36 @@ -import { withWalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier' +import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton' +import { useWalletStatus } from '@/src/hooks/useWalletStatus' +import type { ChainsIds } from '@/src/lib/networks.config' import { useTransactionNotification } from '@/src/providers/TransactionNotificationProvider' +import { ConnectWalletButton } from '@/src/providers/Web3Provider' import { type ButtonProps, chakra } from '@chakra-ui/react' -import type { FC } from 'react' +import type { FC, ReactElement } from 'react' import { useSignMessage } from 'wagmi' interface SignButtonProps extends Omit { + /** Target chain ID for wallet status verification. */ + chainId?: ChainsIds + /** Custom fallback when wallet needs connection. Defaults to ConnectWalletButton. */ + fallback?: ReactElement + /** Alternative label for the button. */ label?: string + /** Button label while signing. Defaults to 'Signing...'. */ labelSigning?: string + /** The message to sign. */ message: string + /** Callback function called when an error occurs. */ onError?: (error: Error) => void + /** Callback function called when the message is signed. */ onSign?: (signature: string) => void + /** Label for the switch chain button. Defaults to 'Switch to'. */ + switchChainLabel?: string } /** - * SignButton component that allows users to sign a message. + * Self-contained message signing button with wallet verification. * - * @param {SignButtonProps} props - SignButton component props. - * @param {string} props.message - The message to sign. - * @param {string|ReactNode} [props.children='Sign Message'] - The content to display in the button. - * @param {boolean} [props.disabled] - Whether the button is disabled. - * @param {(signature: string) => void} [props.onSign] - Callback function called when the message is signed. - * @param {(error: Error) => void} [props.onError] - Callback function called when an error occurs. - * @param {string} [props.label='Sign Message'] - The label for the button (alternative to children). - * @param {string} [props.labelSigning='Signing...'] - The label for the button when the message is being signed. - * @param {ButtonProps} [props.restProps] - Additional props inherited from Chakra UI ButtonProps. + * Handles wallet connection status internally — shows a connect button if not connected, + * a switch chain button if on the wrong chain, or the sign button when ready. * * @example * ```tsx @@ -34,44 +41,59 @@ interface SignButtonProps extends Omit { * /> * ``` */ -const SignButton: FC = withWalletStatusVerifier( - ({ - children = 'Sign Message', - disabled, - labelSigning = 'Signing...', - message, - onError, - onSign, - ...restProps - }: SignButtonProps) => { - const { watchSignature } = useTransactionNotification() +const SignButton: FC = ({ + chainId, + children = 'Sign Message', + disabled, + fallback = , + labelSigning = 'Signing...', + message, + onError, + onSign, + switchChainLabel = 'Switch to', + ...restProps +}) => { + const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } = + useWalletStatus({ chainId }) + const { watchSignature } = useTransactionNotification() - const { isPending, signMessageAsync } = useSignMessage({ - mutation: { - onSuccess(data) { - onSign?.(data) - }, - onError(error) { - onError?.(error) - }, + const { isPending, signMessageAsync } = useSignMessage({ + mutation: { + onSuccess(data) { + onSign?.(data) }, - }) + onError(error) { + onError?.(error) + }, + }, + }) + + if (needsConnect) { + return fallback + } + if (needsChainSwitch) { return ( - { - watchSignature({ - message: 'Signing message...', - signaturePromise: signMessageAsync({ message }), - }) - }} - {...restProps} - > - {isPending ? labelSigning : children} - + switchChain(targetChainId)}> + {switchChainLabel} {targetChain.name} + ) - }, -) + } + + return ( + { + watchSignature({ + message: 'Signing message...', + signaturePromise: signMessageAsync({ message }), + }) + }} + {...restProps} + > + {isPending ? labelSigning : children} + + ) +} export default SignButton diff --git a/src/components/sharedComponents/TransactionButton.test.tsx b/src/components/sharedComponents/TransactionButton.test.tsx index 74e4686d..deb77f9d 100644 --- a/src/components/sharedComponents/TransactionButton.test.tsx +++ b/src/components/sharedComponents/TransactionButton.test.tsx @@ -1,146 +1,154 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { type ReactNode, createElement } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' import TransactionButton from './TransactionButton' -const system = createSystem(defaultConfig) +const mockSwitchChain = vi.fn() +const mockWatchTx = vi.fn() +const mockTransaction = vi.fn(() => Promise.resolve('0xabc' as `0x${string}`)) + +vi.mock('@/src/hooks/useWalletStatus', () => ({ + useWalletStatus: vi.fn(() => ({ + isReady: false, + needsConnect: true, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' }, + targetChainId: 1, + switchChain: mockSwitchChain, + })), +})) -vi.mock('@/src/hooks/useWeb3Status', () => ({ - useWeb3Status: vi.fn(), +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => + createElement( + 'button', + { type: 'button', 'data-testid': 'connect-wallet-button' }, + 'Connect Wallet', + ), })) vi.mock('@/src/providers/TransactionNotificationProvider', () => ({ useTransactionNotification: vi.fn(() => ({ - watchTx: vi.fn(), - watchHash: vi.fn(), - watchSignature: vi.fn(), + watchTx: mockWatchTx, })), })) vi.mock('wagmi', () => ({ - useWaitForTransactionReceipt: vi.fn(() => ({ data: undefined })), + useWaitForTransactionReceipt: vi.fn(() => ({ + data: undefined, + })), })) -vi.mock('@/src/providers/Web3Provider', () => ({ - ConnectWalletButton: () => , -})) +const { useWalletStatus } = await import('@/src/hooks/useWalletStatus') +const mockedUseWalletStatus = vi.mocked(useWalletStatus) -import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status' -import * as wagmiModule from 'wagmi' - -// chains[0] = optimismSepolia (id: 11155420) when PUBLIC_INCLUDE_TESTNETS=true (default) -const OP_SEPOLIA_ID = 11155420 as const - -function connectedStatus() { - return { - isWalletConnected: true, - isWalletSynced: true, - walletChainId: OP_SEPOLIA_ID, - appChainId: OP_SEPOLIA_ID, - address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, - balance: undefined, - connectingWallet: false, - switchingChain: false, - walletClient: undefined, - readOnlyClient: undefined, - switchChain: vi.fn(), - disconnect: vi.fn(), - } -} - -// biome-ignore lint/suspicious/noExplicitAny: test helper accepts flexible props -function renderButton(props: any = {}) { - return render( - - Promise.resolve('0x1' as `0x${string}`)} - {...props} - /> - , - ) -} +const system = createSystem(defaultConfig) + +const renderWithChakra = (ui: ReactNode) => + render({ui}) describe('TransactionButton', () => { - it('renders fallback when wallet not connected', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue({ - ...connectedStatus(), - isWalletConnected: false, - isWalletSynced: false, - }) - renderButton() - expect(screen.getByText('Connect Wallet')).toBeDefined() + beforeEach(() => { + vi.clearAllMocks() }) - it('renders switch chain button when wallet is on wrong chain', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue({ - ...connectedStatus(), - isWalletSynced: false, - walletChainId: 1, + it('renders connect button when wallet needs connect', () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: false, + needsConnect: true, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], + targetChainId: 1, + switchChain: mockSwitchChain, }) - renderButton() - expect(screen.getByRole('button').textContent?.toLowerCase()).toContain('switch to') - }) - it('renders with default label when wallet is connected and synced', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) - vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({ - data: undefined, - } as ReturnType) - renderButton() - expect(screen.getByText('Send Transaction')).toBeDefined() - }) + renderWithChakra(Send) - it('renders with custom children label', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) - vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({ - data: undefined, - } as ReturnType) - renderButton({ children: 'Deposit ETH' }) - expect(screen.getByText('Deposit ETH')).toBeDefined() + expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument() + expect(screen.queryByText('Send')).toBeNull() }) - it('shows labelSending while transaction is pending', async () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) - vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({ - data: undefined, - } as ReturnType) - - const neverResolves = () => new Promise<`0x${string}`>(() => {}) - renderButton({ transaction: neverResolves, labelSending: 'Processing...' }) + it('renders custom fallback when provided and wallet needs connect', () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: false, + needsConnect: true, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], + targetChainId: 1, + switchChain: mockSwitchChain, + }) - expect(screen.getByRole('button').textContent).not.toContain('Processing...') + renderWithChakra( + + Send + , + ) - fireEvent.click(screen.getByRole('button')) + expect(screen.getByTestId('custom-fallback')).toBeInTheDocument() + expect(screen.queryByText('Send')).toBeNull() + }) - await waitFor(() => { - expect(screen.getByRole('button').textContent).toContain('Processing...') + it('renders switch chain button when wallet needs chain switch', () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: false, + needsConnect: false, + needsChainSwitch: true, + targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType< + typeof useWalletStatus + >['targetChain'], + targetChainId: 10, + switchChain: mockSwitchChain, }) + + renderWithChakra(Send) + + expect(screen.getByText(/Switch to/)).toBeInTheDocument() + expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument() + expect(screen.queryByText('Send')).toBeNull() }) - it('calls onMined when receipt becomes available', async () => { - // biome-ignore lint/suspicious/noExplicitAny: mock receipt shape - const mockReceipt = { status: 'success', transactionHash: '0x1' } as any - const onMined = vi.fn() - - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) - // Only return a receipt when called with the matching hash so the mock - // doesn't fire prematurely before the transaction is submitted. - vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockImplementation( - (config) => - ({ - data: config?.hash === '0x1' ? mockReceipt : undefined, - }) as ReturnType, + it('renders custom switch chain label when provided', () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: false, + needsConnect: false, + needsChainSwitch: true, + targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType< + typeof useWalletStatus + >['targetChain'], + targetChainId: 10, + switchChain: mockSwitchChain, + }) + + renderWithChakra( + + Send + , ) - renderButton({ - transaction: () => Promise.resolve('0x1' as `0x${string}`), - onMined, + expect(screen.getByText(/Change to/)).toBeInTheDocument() + expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument() + }) + + it('renders transaction button when wallet is ready', () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: true, + needsConnect: false, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], + targetChainId: 1, + switchChain: mockSwitchChain, }) - fireEvent.click(screen.getByRole('button')) + renderWithChakra(Send ETH) - await waitFor(() => { - expect(onMined).toHaveBeenCalledWith(mockReceipt) - }) + expect(screen.getByText('Send ETH')).toBeInTheDocument() + expect(screen.queryByTestId('connect-wallet-button')).toBeNull() }) }) diff --git a/src/components/sharedComponents/TransactionButton.tsx b/src/components/sharedComponents/TransactionButton.tsx index 8710b8a3..fd43ae32 100644 --- a/src/components/sharedComponents/TransactionButton.tsx +++ b/src/components/sharedComponents/TransactionButton.tsx @@ -1,15 +1,29 @@ -import { withWalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier' import PrimaryButton from '@/src/components/sharedComponents/ui/PrimaryButton' +import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton' +import { useWalletStatus } from '@/src/hooks/useWalletStatus' +import type { ChainsIds } from '@/src/lib/networks.config' import { useTransactionNotification } from '@/src/providers/TransactionNotificationProvider' +import { ConnectWalletButton } from '@/src/providers/Web3Provider' import type { ButtonProps } from '@chakra-ui/react' import { useEffect, useState } from 'react' +import type { ReactElement } from 'react' import type { Hash, TransactionReceipt } from 'viem' import { useWaitForTransactionReceipt } from 'wagmi' interface TransactionButtonProps extends ButtonProps { + /** Target chain ID for wallet status verification. */ + chainId?: ChainsIds + /** Number of confirmations to wait for. Defaults to 1. */ confirmations?: number + /** Custom fallback when wallet needs connection. Defaults to ConnectWalletButton. */ + fallback?: ReactElement + /** Button label during pending transaction. Defaults to 'Sending...'. */ labelSending?: string + /** Callback function called when transaction is mined. */ onMined?: (receipt: TransactionReceipt) => void + /** Label for the switch chain button. Defaults to 'Switch to'. */ + switchChainLabel?: string + /** Function that initiates the transaction and returns a hash. */ transaction: { (): Promise methodId?: string @@ -17,20 +31,10 @@ interface TransactionButtonProps extends ButtonProps { } /** - * TransactionButton component that handles blockchain transaction submission and monitoring. + * Self-contained transaction button with wallet verification, submission, and confirmation tracking. * - * Integrates with writeContractSync or sendTransactionSync functions to handle transaction - * submission and wait for confirmation. Displays transaction status and calls the onMined - * callback when the transaction is confirmed. - * - * @param {TransactionButtonProps} props - TransactionButton component props. - * @param {() => Promise} props.transaction - Function that initiates the transaction. - * @param {(receipt: TransactionReceipt) => void} [props.onMined] - Callback function called when transaction is mined. - * @param {boolean} [props.disabled] - Whether the button is disabled. - * @param {string} [props.labelSending='Sending...'] - Button label during pending transaction. - * @param {number} [props.confirmations=1] - Number of confirmations to wait for. - * @param {ReactNode} [props.children='Send Transaction'] - Button content. - * @param {ButtonProps} props.restProps - Additional props inherited from Chakra UI ButtonProps. + * Handles wallet connection status internally — shows a connect button if not connected, + * a switch chain button if on the wrong chain, or the transaction button when ready. * * @example * ```tsx @@ -44,60 +48,76 @@ interface TransactionButtonProps extends ButtonProps { * * ``` */ -const TransactionButton = withWalletStatusVerifier( - ({ - children = 'Send Transaction', - confirmations = 1, - disabled, - labelSending = 'Sending...', - onMined, - transaction, - ...restProps - }) => { - const [hash, setHash] = useState() - const [isPending, setIsPending] = useState(false) - - const { watchTx } = useTransactionNotification() - const { data: receipt } = useWaitForTransactionReceipt({ - hash: hash, - confirmations, - }) +function TransactionButton({ + chainId, + children = 'Send Transaction', + confirmations = 1, + disabled, + fallback = , + labelSending = 'Sending...', + onMined, + switchChainLabel = 'Switch to', + transaction, + ...restProps +}: TransactionButtonProps) { + const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } = + useWalletStatus({ chainId }) - useEffect(() => { - const handleMined = async () => { - if (receipt && isPending) { - await onMined?.(receipt) - setIsPending(false) - setHash(undefined) - } - } + const [hash, setHash] = useState() + const [isPending, setIsPending] = useState(false) - handleMined() - }, [isPending, onMined, receipt]) + const { watchTx } = useTransactionNotification() + const { data: receipt } = useWaitForTransactionReceipt({ + hash: hash, + confirmations, + }) - const handleSendTransaction = async () => { - setIsPending(true) - try { - const txPromise = transaction() - watchTx({ txPromise, methodId: transaction.methodId }) - const hash = await txPromise - setHash(hash) - } catch (error: unknown) { - console.error('Error sending transaction', error instanceof Error ? error.message : error) + useEffect(() => { + const handleMined = async () => { + if (receipt && isPending) { + await onMined?.(receipt) setIsPending(false) + setHash(undefined) } } + handleMined() + }, [isPending, onMined, receipt]) + + if (needsConnect) { + return fallback + } + + if (needsChainSwitch) { return ( - - {isPending ? labelSending : children} - + switchChain(targetChainId)}> + {switchChainLabel} {targetChain.name} + ) - }, -) + } + + const handleSendTransaction = async () => { + setIsPending(true) + try { + const txPromise = transaction() + watchTx({ txPromise, methodId: transaction.methodId }) + const hash = await txPromise + setHash(hash) + } catch (error: unknown) { + console.error('Error sending transaction', error instanceof Error ? error.message : error) + setIsPending(false) + } + } + + return ( + + {isPending ? labelSending : children} + + ) +} export default TransactionButton diff --git a/src/components/sharedComponents/WalletStatusVerifier.test.tsx b/src/components/sharedComponents/WalletStatusVerifier.test.tsx index 09c5b00d..9f188e66 100644 --- a/src/components/sharedComponents/WalletStatusVerifier.test.tsx +++ b/src/components/sharedComponents/WalletStatusVerifier.test.tsx @@ -1,122 +1,156 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' import { render, screen } from '@testing-library/react' -import type { ReactElement } from 'react' -import { describe, expect, it, vi } from 'vitest' -import { WalletStatusVerifier, withWalletStatusVerifier } from './WalletStatusVerifier' +import userEvent from '@testing-library/user-event' +import { type ReactNode, createElement } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WalletStatusVerifier } from './WalletStatusVerifier' -const system = createSystem(defaultConfig) +const mockSwitchChain = vi.fn() -vi.mock('@/src/hooks/useWeb3Status', () => ({ - useWeb3Status: vi.fn(), +vi.mock('@/src/hooks/useWalletStatus', () => ({ + useWalletStatus: vi.fn(() => ({ + isReady: false, + needsConnect: true, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' }, + targetChainId: 1, + switchChain: mockSwitchChain, + })), })) vi.mock('@/src/providers/Web3Provider', () => ({ - ConnectWalletButton: () => , + ConnectWalletButton: () => + createElement( + 'button', + { type: 'button', 'data-testid': 'connect-wallet-button' }, + 'Connect Wallet', + ), })) -import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status' - -// chains[0] = optimismSepolia (id: 11155420) when PUBLIC_INCLUDE_TESTNETS=true (default) -const OP_SEPOLIA_ID = 11155420 as const - -function connectedSyncedStatus(overrides = {}) { - return { - isWalletConnected: true, - isWalletSynced: true, - walletChainId: OP_SEPOLIA_ID, - appChainId: OP_SEPOLIA_ID, - switchChain: vi.fn(), - disconnect: vi.fn(), - address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, - balance: undefined, - connectingWallet: false, - switchingChain: false, - walletClient: undefined, - readOnlyClient: undefined, - ...overrides, - } -} - -function wrap(ui: ReactElement) { - return render({ui}) -} +const { useWalletStatus } = await import('@/src/hooks/useWalletStatus') +const mockedUseWalletStatus = vi.mocked(useWalletStatus) + +const system = createSystem(defaultConfig) + +const renderWithChakra = (ui: ReactNode) => + render({ui}) describe('WalletStatusVerifier', () => { - it('renders default ConnectWalletButton fallback when wallet not connected', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any, - ) - wrap( - -

Protected Content
-
, - ) - expect(screen.getByText('Connect Wallet')).toBeDefined() - expect(screen.queryByText('Protected Content')).toBeNull() + beforeEach(() => { + vi.clearAllMocks() }) - it('renders custom fallback when wallet not connected', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any, - ) - wrap( - Custom Fallback}> -
Protected Content
-
, + it('renders default fallback (ConnectWalletButton) when wallet needs connect', () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: false, + needsConnect: true, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], + targetChainId: 1, + switchChain: mockSwitchChain, + }) + + renderWithChakra( + createElement( + WalletStatusVerifier, + null, + createElement('div', { 'data-testid': 'protected-content' }, 'Protected'), + ), ) - expect(screen.getByText('Custom Fallback')).toBeDefined() + + expect(screen.getByTestId('connect-wallet-button')).toBeInTheDocument() + expect(screen.queryByTestId('protected-content')).toBeNull() }) - it('renders switch chain button when wallet is on wrong chain', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - connectedSyncedStatus({ isWalletSynced: false, walletChainId: 1 }) as any, - ) - wrap( - -
Protected Content
-
, + it('renders custom fallback when provided and wallet needs connect', () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: false, + needsConnect: true, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], + targetChainId: 1, + switchChain: mockSwitchChain, + }) + + renderWithChakra( + createElement( + WalletStatusVerifier, + { fallback: createElement('div', { 'data-testid': 'custom-fallback' }, 'Custom') }, + createElement('div', { 'data-testid': 'protected-content' }, 'Protected'), + ), ) - expect(screen.getByRole('button').textContent?.toLowerCase()).toContain('switch to') - expect(screen.queryByText('Protected Content')).toBeNull() + + expect(screen.getByTestId('custom-fallback')).toBeInTheDocument() + expect(screen.queryByTestId('protected-content')).toBeNull() }) - it('renders children when wallet is connected and synced', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - connectedSyncedStatus() as any, - ) - wrap( - -
Protected Content
-
, + it('renders switch chain button when wallet needs chain switch', () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: false, + needsConnect: false, + needsChainSwitch: true, + targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType< + typeof useWalletStatus + >['targetChain'], + targetChainId: 10, + switchChain: mockSwitchChain, + }) + + renderWithChakra( + createElement( + WalletStatusVerifier, + null, + createElement('div', { 'data-testid': 'protected-content' }, 'Protected'), + ), ) - expect(screen.getByText('Protected Content')).toBeDefined() + + expect(screen.getByText(/Switch to/)).toBeInTheDocument() + expect(screen.getByText(/OP Mainnet/)).toBeInTheDocument() + expect(screen.queryByTestId('protected-content')).toBeNull() }) -}) -describe('withWalletStatusVerifier HOC', () => { - const ProtectedComponent = () =>
Protected Component
- const Wrapped = withWalletStatusVerifier(ProtectedComponent) + it('renders children when wallet is ready', () => { + mockedUseWalletStatus.mockReturnValue({ + isReady: true, + needsConnect: false, + needsChainSwitch: false, + targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], + targetChainId: 1, + switchChain: mockSwitchChain, + }) - it('renders fallback when wallet not connected', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any, + renderWithChakra( + createElement( + WalletStatusVerifier, + null, + createElement('div', { 'data-testid': 'protected-content' }, 'Protected'), + ), ) - wrap() - expect(screen.getByText('Connect Wallet')).toBeDefined() - expect(screen.queryByText('Protected Component')).toBeNull() + + expect(screen.getByTestId('protected-content')).toBeInTheDocument() }) - it('renders wrapped component when wallet is connected and synced', () => { - vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( - // biome-ignore lint/suspicious/noExplicitAny: partial mock - connectedSyncedStatus() as any, + it('calls switchChain when switch button is clicked', async () => { + const user = userEvent.setup() + + mockedUseWalletStatus.mockReturnValue({ + isReady: false, + needsConnect: false, + needsChainSwitch: true, + targetChain: { id: 10, name: 'OP Mainnet' } as ReturnType< + typeof useWalletStatus + >['targetChain'], + targetChainId: 10, + switchChain: mockSwitchChain, + }) + + renderWithChakra( + createElement(WalletStatusVerifier, null, createElement('div', null, 'Protected')), ) - wrap() - expect(screen.getByText('Protected Component')).toBeDefined() + + const switchButton = screen.getByText(/Switch to/) + await user.click(switchButton) + + expect(mockSwitchChain).toHaveBeenCalledWith(10) }) }) diff --git a/src/components/sharedComponents/WalletStatusVerifier.tsx b/src/components/sharedComponents/WalletStatusVerifier.tsx index 7e9d46c2..d5cfef50 100644 --- a/src/components/sharedComponents/WalletStatusVerifier.tsx +++ b/src/components/sharedComponents/WalletStatusVerifier.tsx @@ -1,20 +1,8 @@ -import PrimaryButton from '@/src/components/sharedComponents/ui/PrimaryButton' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' -import { type ChainsIds, chains } from '@/src/lib/networks.config' +import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton' +import { useWalletStatus } from '@/src/hooks/useWalletStatus' +import type { ChainsIds } from '@/src/lib/networks.config' import { ConnectWalletButton } from '@/src/providers/Web3Provider' -import { chakra } from '@chakra-ui/react' -import type { ComponentType, FC, ReactElement } from 'react' -import { extractChain } from 'viem' - -const Button = chakra(PrimaryButton, { - base: { - fontSize: '16px', - fontWeight: 500, - height: '48px', - paddingLeft: 6, - paddingRight: 6, - }, -}) +import type { FC, ReactElement } from 'react' interface WalletStatusVerifierProps { chainId?: ChainsIds @@ -24,21 +12,14 @@ interface WalletStatusVerifierProps { } /** - * WalletStatusVerifier Component - * - * This component checks the wallet connection and chain synchronization status. - * If the wallet is not connected, it displays a fallback component (default: ConnectWalletButton) - * If the wallet is connected but not synced with the correct chain, it provides an option to switch chain. + * Wrapper component that gates content on wallet connection and chain status. * - * @param {Object} props - WalletStatusVerifier component props - * @param {Chain['id']} [props.chainId] - The chain ID to check for synchronization - * @param {ReactElement} [props.fallback] - The fallback component to render if the wallet is not connected - * @param {ReactElement} props.children - The children components to render if the wallet is connected and synced + * This is the primary API for protecting UI that requires a connected wallet. * * @example * ```tsx * - * + * * * ``` */ @@ -48,75 +29,22 @@ const WalletStatusVerifier: FC = ({ fallback = , labelSwitchChain = 'Switch to', }: WalletStatusVerifierProps) => { - const { appChainId, isWalletConnected, isWalletSynced, switchChain, walletChainId } = - useWeb3Status() + const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } = + useWalletStatus({ chainId }) - const chainToSwitch = extractChain({ chains, id: chainId || appChainId || chains[0].id }) - - if (!isWalletConnected) { + if (needsConnect) { return fallback } - if (!isWalletSynced || walletChainId !== chainToSwitch.id) { + if (needsChainSwitch) { return ( - + switchChain(targetChainId)}> + {labelSwitchChain} {targetChain.name} + ) } return children } -/** - * WalletStatusVerifier Component - * - * Checks the wallet connection and chain synchronization status. - * - If wallet is not connected, displays fallback component (default: ConnectWalletButton) - * - If wallet is connected but on wrong chain, provides option to switch networks - * - If wallet is connected and on correct chain, renders children - * - * @param {WalletStatusVerifierProps} props - Component props - * @param {ChainsIds} [props.chainId] - The required chain ID (defaults to appChainId) - * @param {ReactElement} [props.children] - The content to render when wallet is connected and synced - * @param {ReactElement} [props.fallback=] - Component to render when wallet is not connected - * @param {string} [props.labelSwitchChain='Switch to'] - Label for the chain switching button - * - * @example - * ```tsx - * - * - * - * ``` - */ -const withWalletStatusVerifier =

( - WrappedComponent: ComponentType

, - { - chainId, - fallback = , - labelSwitchChain = 'Switch to', - }: WalletStatusVerifierProps = {}, -): FC

=> { - const ComponentWithVerifier: FC

= (props: P) => { - const { appChainId, isWalletConnected, isWalletSynced, switchChain, walletChainId } = - useWeb3Status() - - const chainToSwitch = extractChain({ chains, id: chainId || appChainId || chains[0].id }) - - return !isWalletConnected ? ( - fallback - ) : !isWalletSynced || walletChainId !== chainToSwitch.id ? ( - - ) : ( - - ) - } - - ComponentWithVerifier.displayName = `withWalletStatusVerifier(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})` - - return ComponentWithVerifier -} - -export { WalletStatusVerifier, withWalletStatusVerifier } +export { WalletStatusVerifier } diff --git a/src/components/sharedComponents/ui/SwitchChainButton.tsx b/src/components/sharedComponents/ui/SwitchChainButton.tsx new file mode 100644 index 00000000..02fc0d1d --- /dev/null +++ b/src/components/sharedComponents/ui/SwitchChainButton.tsx @@ -0,0 +1,14 @@ +import PrimaryButton from '@/src/components/sharedComponents/ui/PrimaryButton' +import { chakra } from '@chakra-ui/react' + +const SwitchChainButton = chakra(PrimaryButton, { + base: { + fontSize: '16px', + fontWeight: 500, + height: '48px', + paddingLeft: 6, + paddingRight: 6, + }, +}) + +export default SwitchChainButton diff --git a/src/hooks/useWalletStatus.test.ts b/src/hooks/useWalletStatus.test.ts new file mode 100644 index 00000000..ce5bd0fd --- /dev/null +++ b/src/hooks/useWalletStatus.test.ts @@ -0,0 +1,160 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useWalletStatus } from './useWalletStatus' + +// Mock useWeb3Status +const mockSwitchChain = vi.fn() +const mockDisconnect = vi.fn() + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => ({ + appChainId: 1, + isWalletConnected: false, + isWalletSynced: false, + switchChain: mockSwitchChain, + walletChainId: undefined, + })), +})) + +vi.mock('@/src/lib/networks.config', () => ({ + chains: [ + { id: 1, name: 'Ethereum' }, + { id: 10, name: 'OP Mainnet' }, + { id: 137, name: 'Polygon' }, + ], +})) + +vi.mock('viem', async () => { + const actual = await vi.importActual('viem') + return { + ...actual, + extractChain: vi.fn(({ chains, id }) => { + const chain = chains.find((c: { id: number }) => c.id === id) + if (!chain) { + throw new Error(`Chain with id ${id} not found`) + } + return chain + }), + } +}) + +// Import after mocks are set up +const { useWeb3Status } = await import('@/src/hooks/useWeb3Status') +const mockedUseWeb3Status = vi.mocked(useWeb3Status) + +const baseWeb3Status: ReturnType = { + readOnlyClient: undefined, + appChainId: 1, + address: undefined, + balance: undefined, + connectingWallet: false, + switchingChain: false, + isWalletConnected: false, + walletClient: undefined, + isWalletSynced: false, + walletChainId: undefined, + switchChain: mockSwitchChain, + disconnect: mockDisconnect, +} + +describe('useWalletStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns needsConnect when wallet is not connected', () => { + mockedUseWeb3Status.mockReturnValue({ + ...baseWeb3Status, + appChainId: 1, + isWalletConnected: false, + isWalletSynced: false, + walletChainId: undefined, + }) + + const { result } = renderHook(() => useWalletStatus()) + + expect(result.current.needsConnect).toBe(true) + expect(result.current.needsChainSwitch).toBe(false) + expect(result.current.isReady).toBe(false) + }) + + it('returns needsChainSwitch when connected but on wrong chain', () => { + mockedUseWeb3Status.mockReturnValue({ + ...baseWeb3Status, + appChainId: 1, + isWalletConnected: true, + isWalletSynced: false, + walletChainId: 137, + }) + + const { result } = renderHook(() => useWalletStatus()) + + expect(result.current.needsConnect).toBe(false) + expect(result.current.needsChainSwitch).toBe(true) + expect(result.current.isReady).toBe(false) + expect(result.current.targetChain).toEqual({ id: 1, name: 'Ethereum' }) + expect(result.current.targetChainId).toBe(1) + }) + + it('returns isReady when connected and on correct chain', () => { + mockedUseWeb3Status.mockReturnValue({ + ...baseWeb3Status, + appChainId: 1, + isWalletConnected: true, + isWalletSynced: true, + walletChainId: 1, + }) + + const { result } = renderHook(() => useWalletStatus()) + + expect(result.current.needsConnect).toBe(false) + expect(result.current.needsChainSwitch).toBe(false) + expect(result.current.isReady).toBe(true) + }) + + it('uses provided chainId over appChainId', () => { + mockedUseWeb3Status.mockReturnValue({ + ...baseWeb3Status, + appChainId: 1, + isWalletConnected: true, + isWalletSynced: true, + walletChainId: 1, + }) + + const { result } = renderHook(() => useWalletStatus({ chainId: 10 })) + + expect(result.current.needsChainSwitch).toBe(true) + expect(result.current.isReady).toBe(false) + expect(result.current.targetChain).toEqual({ id: 10, name: 'OP Mainnet' }) + expect(result.current.targetChainId).toBe(10) + }) + + it('falls back to chains[0].id when no chainId or appChainId', () => { + mockedUseWeb3Status.mockReturnValue({ + ...baseWeb3Status, + appChainId: undefined as unknown as ReturnType['appChainId'], + isWalletConnected: true, + isWalletSynced: false, + walletChainId: 137, + }) + + const { result } = renderHook(() => useWalletStatus()) + + expect(result.current.targetChain).toEqual({ id: 1, name: 'Ethereum' }) + }) + + it('switchChain calls through to useWeb3Status switchChain', () => { + mockedUseWeb3Status.mockReturnValue({ + ...baseWeb3Status, + appChainId: 1, + isWalletConnected: true, + isWalletSynced: false, + walletChainId: 137, + }) + + const { result } = renderHook(() => useWalletStatus()) + + result.current.switchChain(10) + expect(mockSwitchChain).toHaveBeenCalledWith(10) + }) +}) diff --git a/src/hooks/useWalletStatus.ts b/src/hooks/useWalletStatus.ts new file mode 100644 index 00000000..3afae7cc --- /dev/null +++ b/src/hooks/useWalletStatus.ts @@ -0,0 +1,39 @@ +import { extractChain } from 'viem' +import type { Chain } from 'viem' + +import { useWeb3Status } from '@/src/hooks/useWeb3Status' +import { type ChainsIds, chains } from '@/src/lib/networks.config' + +interface UseWalletStatusOptions { + chainId?: ChainsIds +} + +interface WalletStatus { + isReady: boolean + needsConnect: boolean + needsChainSwitch: boolean + targetChain: Chain + targetChainId: ChainsIds + switchChain: (chainId: ChainsIds) => void +} + +export const useWalletStatus = (options?: UseWalletStatusOptions): WalletStatus => { + const { appChainId, isWalletConnected, isWalletSynced, switchChain, walletChainId } = + useWeb3Status() + + const targetChainId = options?.chainId || appChainId || chains[0].id + const targetChain = extractChain({ chains, id: targetChainId }) + + const needsConnect = !isWalletConnected + const needsChainSwitch = isWalletConnected && (!isWalletSynced || walletChainId !== targetChainId) + const isReady = isWalletConnected && !needsChainSwitch + + return { + isReady, + needsConnect, + needsChainSwitch, + targetChain, + targetChainId, + switchChain, + } +} diff --git a/tsconfig.json b/tsconfig.json index e1c3e446..df3a50b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "skipLibCheck": true, "strict": true, "target": "ESNext", - "types": ["vitest/globals"], + "types": ["vitest/globals", "@testing-library/jest-dom/vitest"], "useDefineForClassFields": true, "paths": { "@/src/*": ["./src/*"], diff --git a/typedoc.json b/typedoc.json index 41da282c..c2c7e15f 100644 --- a/typedoc.json +++ b/typedoc.json @@ -51,7 +51,7 @@ "visibilityFilters": { "inherited": true }, - "blockTags": ["@dev", "@source", "@name", "@description"], + "blockTags": ["@dev", "@source", "@name", "@description", "@param", "@returns", "@example", "@throws"], "validation": { "invalidLink": true, "notDocumented": false