({
+ useWalletAdvancedContext: vi.fn(),
+}));
+
+vi.mock('./SendProvider', () => ({
+ useSendContext: vi.fn(),
+}));
+
+vi.mock('@/internal/components/PressableIcon', () => ({
+ PressableIcon: vi.fn(({ children, onClick, className }) => (
+
+ )),
+}));
+
+vi.mock('@/internal/svg/backArrowSvg', () => ({
+ backArrowSvg:
Back Arrow
,
+}));
+
+vi.mock('@/internal/svg/closeSvg', () => ({
+ CloseSvg: () =>
Close
,
+}));
+
+describe('SendHeader', () => {
+ const mockUseWalletAdvancedContext = useWalletAdvancedContext as ReturnType<
+ typeof vi.fn
+ >;
+ const mockUseSendContext = useSendContext as ReturnType
;
+
+ const mockWalletAdvancedContext = {
+ setActiveFeature: vi.fn(),
+ };
+
+ const mockSendContext = {
+ selectedRecipientAddress: { value: null, display: null },
+ selectedToken: null,
+ handleResetTokenSelection: vi.fn(),
+ handleRecipientInputChange: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseWalletAdvancedContext.mockReturnValue(mockWalletAdvancedContext);
+ mockUseSendContext.mockReturnValue(mockSendContext);
+ });
+
+ it('renders with default label', () => {
+ render();
+
+ expect(screen.getByText('Send')).toBeInTheDocument();
+ expect(screen.getByTestId('mock-close-svg')).toBeInTheDocument();
+ expect(screen.queryByTestId('mock-back-arrow')).not.toBeInTheDocument();
+ });
+
+ it('renders with custom label', () => {
+ render();
+
+ expect(screen.getByText('Custom Send')).toBeInTheDocument();
+ });
+
+ it('applies custom classNames', () => {
+ const customClassNames = {
+ container: 'custom-container',
+ label: 'custom-label',
+ close: 'custom-close',
+ back: 'custom-back',
+ };
+
+ mockUseSendContext.mockReturnValue({
+ ...mockSendContext,
+ selectedRecipientAddress: { value: '0x123', display: 'user.eth' },
+ });
+
+ render();
+
+ const container = screen.queryByTestId('ockSendHeader');
+ expect(container).toHaveClass('custom-container');
+
+ const label = screen.queryByTestId('ockSendHeader_label');
+ expect(label).toHaveClass('custom-label');
+
+ const backButton = screen.queryByTestId('ockSendHeader_back');
+ expect(backButton?.firstElementChild).toHaveClass('custom-back');
+
+ const closeButton = screen.queryByTestId('ockSendHeader_close');
+ expect(closeButton?.firstElementChild).toHaveClass('custom-close');
+ });
+
+ it('shows back button when recipient address is selected', () => {
+ mockUseSendContext.mockReturnValue({
+ ...mockSendContext,
+ selectedRecipientAddress: {
+ value: '0x1234567890123456789012345678901234567890',
+ display: 'user.eth',
+ },
+ });
+
+ render();
+
+ expect(screen.getByTestId('mock-back-arrow')).toBeInTheDocument();
+ });
+
+ it('calls handleClose when close button is clicked', () => {
+ render();
+
+ const closeButton = screen.getByTestId('mock-pressable-icon');
+ fireEvent.click(closeButton);
+
+ expect(mockWalletAdvancedContext.setActiveFeature).toHaveBeenCalledWith(
+ null,
+ );
+ });
+
+ it('calls handleResetTokenSelection when back button is clicked and token is selected', () => {
+ mockUseSendContext.mockReturnValue({
+ ...mockSendContext,
+ selectedRecipientAddress: { value: '0x123', display: 'user.eth' },
+ selectedToken: { symbol: 'ETH' },
+ });
+
+ render();
+
+ const backButton = screen.getByTestId('mock-back-arrow');
+ fireEvent.click(backButton);
+
+ expect(mockSendContext.handleResetTokenSelection).toHaveBeenCalled();
+ expect(mockSendContext.handleRecipientInputChange).not.toHaveBeenCalled();
+ });
+
+ it('calls handleRecipientInputChange when back button is clicked and no token is selected', () => {
+ mockUseSendContext.mockReturnValue({
+ ...mockSendContext,
+ selectedRecipientAddress: { value: '0x123', display: 'user.eth' },
+ selectedToken: null,
+ });
+
+ render();
+
+ const backButton = screen.getByTestId('mock-back-arrow');
+ fireEvent.click(backButton);
+
+ expect(mockSendContext.handleRecipientInputChange).toHaveBeenCalled();
+ expect(mockSendContext.handleResetTokenSelection).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/wallet/components/wallet-advanced-send/components/SendHeader.tsx b/src/wallet/components/wallet-advanced-send/components/SendHeader.tsx
new file mode 100644
index 0000000000..bdf92ec66e
--- /dev/null
+++ b/src/wallet/components/wallet-advanced-send/components/SendHeader.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { PressableIcon } from '@/internal/components/PressableIcon';
+import { backArrowSvg } from '@/internal/svg/backArrowSvg';
+import { CloseSvg } from '@/internal/svg/closeSvg';
+import { cn, text } from '@/styles/theme';
+import { useCallback } from 'react';
+import { useWalletAdvancedContext } from '../../WalletAdvancedProvider';
+import { useSendContext } from './SendProvider';
+
+type SendHeaderProps = {
+ label?: string;
+ classNames?: {
+ container?: string;
+ label?: string;
+ close?: string;
+ back?: string;
+ };
+};
+
+export function SendHeader({ label = 'Send', classNames }: SendHeaderProps) {
+ const { setActiveFeature } = useWalletAdvancedContext();
+
+ const {
+ selectedRecipientAddress,
+ selectedToken,
+ handleResetTokenSelection,
+ handleRecipientInputChange,
+ } = useSendContext();
+
+ const handleBack = useCallback(() => {
+ if (selectedToken) {
+ handleResetTokenSelection();
+ } else if (selectedRecipientAddress.value) {
+ handleRecipientInputChange();
+ }
+ }, [
+ selectedRecipientAddress,
+ selectedToken,
+ handleResetTokenSelection,
+ handleRecipientInputChange,
+ ]);
+
+ const handleClose = useCallback(() => {
+ setActiveFeature(null);
+ }, [setActiveFeature]);
+
+ return (
+
+
+ {selectedRecipientAddress.value && (
+
+ {backArrowSvg}
+
+ )}
+
+
+ {label}
+
+
+
+ );
+}
diff --git a/src/wallet/components/wallet-advanced-send/components/SendProvider.test.tsx b/src/wallet/components/wallet-advanced-send/components/SendProvider.test.tsx
new file mode 100644
index 0000000000..c811a3cf42
--- /dev/null
+++ b/src/wallet/components/wallet-advanced-send/components/SendProvider.test.tsx
@@ -0,0 +1,670 @@
+import type { APIError } from '@/api/types';
+import { useExchangeRate } from '@/internal/hooks/useExchangeRate';
+import { useSendTransaction } from '@/internal/hooks/useSendTransaction';
+import { act, render, renderHook } from '@testing-library/react';
+import { formatUnits } from 'viem';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useWalletAdvancedContext } from '../../WalletAdvancedProvider';
+import { SendProvider, useSendContext } from './SendProvider';
+
+vi.mock('../../WalletAdvancedProvider', () => ({
+ useWalletAdvancedContext: vi.fn(),
+}));
+
+vi.mock('@/internal/hooks/useExchangeRate', () => ({
+ useExchangeRate: vi.fn().mockReturnValue(Promise.resolve()),
+}));
+
+vi.mock('@/internal/hooks/useSendTransaction', () => ({
+ useSendTransaction: vi.fn(),
+}));
+
+vi.mock('viem', () => ({
+ formatUnits: vi.fn(),
+}));
+
+describe('useSendContext', () => {
+ const mockUseWalletAdvancedContext = useWalletAdvancedContext as ReturnType<
+ typeof vi.fn
+ >;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseWalletAdvancedContext.mockReturnValue({
+ tokenBalances: [
+ {
+ address: '',
+ symbol: 'ETH',
+ decimals: 18,
+ cryptoBalance: '2000000000000000000',
+ fiatBalance: 4000,
+ },
+ ],
+ });
+
+ vi.mocked(formatUnits).mockReturnValue('2');
+ vi.mocked(useSendTransaction).mockReturnValue({
+ to: '0x1234',
+ data: '0x',
+ });
+ });
+
+ it('should provide send context when used within provider', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ expect(result.current).toEqual({
+ isInitialized: expect.any(Boolean),
+ lifecycleStatus: expect.any(Object),
+ updateLifecycleStatus: expect.any(Function),
+ ethBalance: expect.any(Number),
+ selectedRecipientAddress: expect.any(Object),
+ handleAddressSelection: expect.any(Function),
+ selectedToken: null,
+ handleRecipientInputChange: expect.any(Function),
+ handleTokenSelection: expect.any(Function),
+ handleResetTokenSelection: expect.any(Function),
+ fiatAmount: null,
+ handleFiatAmountChange: expect.any(Function),
+ cryptoAmount: null,
+ handleCryptoAmountChange: expect.any(Function),
+ exchangeRate: expect.any(Number),
+ exchangeRateLoading: expect.any(Boolean),
+ selectedInputType: 'crypto',
+ setSelectedInputType: expect.any(Function),
+ callData: null,
+ });
+ });
+
+ it('should throw an error when used outside of SendProvider', () => {
+ const TestComponent = () => {
+ useSendContext();
+ return null;
+ };
+
+ const originalError = console.error;
+ console.error = vi.fn();
+
+ expect(() => {
+ render();
+ }).toThrow();
+
+ console.error = originalError;
+ });
+
+ it('should initialize and set lifecycle status when the user has an ETH balance', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ expect(result.current.ethBalance).toBe(2);
+ expect(result.current.lifecycleStatus.statusName).toBe('selectingAddress');
+ });
+
+ it('should initialize and set lifecycle status when the user does not have an ETH balance', () => {
+ mockUseWalletAdvancedContext.mockReturnValue({
+ tokenBalances: [
+ {
+ address: '0x0000000000000000000000000000000000000000',
+ symbol: 'USDC',
+ decimals: 6,
+ cryptoBalance: '2000000000000000000',
+ fiatBalance: 4000,
+ },
+ ],
+ });
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ expect(result.current.ethBalance).toBe(0);
+ expect(result.current.lifecycleStatus.statusName).toBe('fundingWallet');
+ });
+
+ it('should handle address selection', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ act(() => {
+ result.current.handleAddressSelection({
+ display: 'user.eth',
+ value: '0x1234',
+ });
+ });
+
+ expect(result.current.selectedRecipientAddress).toEqual({
+ display: 'user.eth',
+ value: '0x1234',
+ });
+ expect(result.current.lifecycleStatus.statusName).toBe('selectingToken');
+ });
+
+ it('should handle recipient input change', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ act(() => {
+ result.current.handleAddressSelection({
+ display: 'user.eth',
+ value: '0x1234',
+ });
+ });
+
+ expect(result.current.selectedRecipientAddress).toEqual({
+ display: 'user.eth',
+ value: '0x1234',
+ });
+
+ act(() => {
+ result.current.handleRecipientInputChange();
+ });
+
+ expect(result.current.selectedRecipientAddress).toEqual({
+ display: '',
+ value: null,
+ });
+
+ expect(result.current.lifecycleStatus.statusName).toBe('selectingAddress');
+ expect(result.current.lifecycleStatus.statusData).toEqual({
+ isMissingRequiredField: true,
+ });
+ });
+
+ it('should handle token selection', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const token = {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: 4000,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleTokenSelection(token);
+ });
+
+ expect(result.current.selectedToken).toEqual(token);
+ expect(result.current.lifecycleStatus.statusName).toBe('amountChange');
+ });
+
+ it('should handle reset token selection', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const token = {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: 4000,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleTokenSelection(token);
+ result.current.handleResetTokenSelection();
+ });
+
+ expect(result.current.selectedToken).toBeNull();
+ expect(result.current.fiatAmount).toBeNull();
+ expect(result.current.cryptoAmount).toBeNull();
+ expect(result.current.lifecycleStatus.statusName).toBe('selectingToken');
+ });
+
+ it('should handle crypto amount change', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const token = {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: 4000,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleTokenSelection(token);
+ result.current.handleCryptoAmountChange('1.0');
+ });
+
+ expect(result.current.cryptoAmount).toBe('1.0');
+ expect(result.current.lifecycleStatus.statusName).toBe('amountChange');
+ });
+
+ it('should handle fiat amount change', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const token = {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: 4000,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleTokenSelection(token);
+ result.current.handleFiatAmountChange('1000');
+ });
+
+ expect(result.current.fiatAmount).toBe('1000');
+ expect(result.current.lifecycleStatus.statusName).toBe('amountChange');
+ });
+
+ it('should handle BigInt conversion in crypto amount change', () => {
+ const formatUnitsSpy = vi.mocked(formatUnits);
+ formatUnitsSpy.mockClear();
+
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const token = {
+ name: 'Test Token',
+ symbol: 'TEST',
+ address: '0x123' as const,
+ decimals: 18,
+ cryptoBalance: 100000000000000,
+ fiatBalance: 1000,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleTokenSelection(token);
+ result.current.handleCryptoAmountChange('1.0');
+ });
+
+ expect(formatUnitsSpy).toHaveBeenCalledWith(expect.any(BigInt), 18);
+
+ const tokenWithNullValues = {
+ name: 'Test Token',
+ symbol: 'TEST',
+ address: '0x123' as const,
+ decimals: undefined as unknown as number,
+ cryptoBalance: undefined as unknown as number,
+ fiatBalance: undefined as unknown as number,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleTokenSelection(tokenWithNullValues);
+ });
+
+ formatUnitsSpy.mockClear();
+
+ act(() => {
+ result.current.handleCryptoAmountChange('1.0');
+ });
+
+ expect(formatUnitsSpy).toHaveBeenCalledWith(BigInt(0), 0);
+ });
+
+ it('should set sufficientBalance correctly when token has fiat balance', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ act(() => {
+ result.current.handleFiatAmountChange('1000');
+ });
+
+ // @ts-ignore - test is not type narrowing
+ expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe(
+ false,
+ );
+
+ const token = {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: 4000,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleTokenSelection(token);
+ });
+
+ act(() => {
+ result.current.handleFiatAmountChange('3999.99');
+ });
+ expect(result.current.fiatAmount).toBe('3999.99');
+ // @ts-ignore - test is not type narrowing
+ expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe(
+ true,
+ );
+
+ act(() => {
+ result.current.handleFiatAmountChange('4000');
+ });
+ expect(result.current.fiatAmount).toBe('4000');
+ // @ts-ignore - test is not type narrowing
+ expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe(
+ true,
+ );
+
+ act(() => {
+ result.current.handleFiatAmountChange('4000.01');
+ });
+ expect(result.current.fiatAmount).toBe('4000.01');
+ // @ts-ignore - test is not type narrowing
+ expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe(
+ false,
+ );
+ });
+
+ it('should set sufficientBalance correctly in handleFiatAmountChange', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ act(() => {
+ result.current.handleFiatAmountChange('1000');
+ });
+ // @ts-ignore - test is not type narrowing
+ expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe(
+ false,
+ );
+
+ const tokenWithNullFiatBalance = {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: undefined as unknown as number,
+ chainId: 8453,
+ image: '',
+ };
+
+ const tokenWithoutBalance = {
+ ...tokenWithNullFiatBalance,
+ };
+
+ act(() => {
+ result.current.handleTokenSelection(tokenWithoutBalance);
+ result.current.handleFiatAmountChange('100');
+ });
+ expect(result.current.fiatAmount).toBe('100');
+ // @ts-ignore - test is not type narrowing
+ expect(result.current.lifecycleStatus.statusData?.sufficientBalance).toBe(
+ false,
+ );
+ });
+
+ it('should handle input type change', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ act(() => {
+ result.current.setSelectedInputType('fiat');
+ });
+
+ expect(result.current.selectedInputType).toBe('fiat');
+ });
+
+ it('should call useExchangeRate with correct parameters when ETH token is selected', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const ethToken = {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: 4000,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleTokenSelection(ethToken);
+ });
+
+ expect(useExchangeRate).toHaveBeenCalledWith({
+ token: 'ETH',
+ selectedInputType: 'crypto',
+ setExchangeRate: expect.any(Function),
+ setExchangeRateLoading: expect.any(Function),
+ });
+ });
+
+ it('should call useExchangeRate with token address for non-ETH tokens', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const usdcToken = {
+ name: 'USD Coin',
+ symbol: 'USDC',
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as const,
+ decimals: 6,
+ cryptoBalance: 5000000,
+ fiatBalance: 5,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleTokenSelection(usdcToken);
+ });
+
+ expect(useExchangeRate).toHaveBeenCalledWith({
+ token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ selectedInputType: 'crypto',
+ setExchangeRate: expect.any(Function),
+ setExchangeRateLoading: expect.any(Function),
+ });
+ });
+
+ it('should call useExchangeRate when selectedInputType changes', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const ethToken = {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: 4000,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleTokenSelection(ethToken);
+ });
+
+ vi.clearAllMocks();
+
+ act(() => {
+ result.current.setSelectedInputType('fiat');
+ });
+
+ expect(useExchangeRate).toHaveBeenCalledWith({
+ token: 'ETH',
+ selectedInputType: 'fiat',
+ setExchangeRate: expect.any(Function),
+ setExchangeRateLoading: expect.any(Function),
+ });
+ });
+
+ it('should not call useExchangeRate if selectedToken has invalid parameters', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const mockToken = {
+ name: 'Mock Token',
+ symbol: 'MOCK',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: 4000,
+ chainId: 8453,
+ image: '',
+ };
+
+ vi.clearAllMocks();
+
+ act(() => {
+ result.current.handleTokenSelection(mockToken);
+ });
+
+ expect(useExchangeRate).not.toHaveBeenCalled();
+ });
+
+ it('should fetch transaction data when all required fields are set', () => {
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const token = {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: 4000,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleAddressSelection({
+ display: 'user.eth',
+ value: '0x1234',
+ });
+ result.current.handleTokenSelection(token);
+ result.current.handleCryptoAmountChange('1.0');
+ });
+
+ expect(useSendTransaction).toHaveBeenCalledWith({
+ recipientAddress: '0x1234',
+ token,
+ amount: '1.0',
+ });
+
+ expect(result.current.callData).toEqual({
+ to: '0x1234',
+ data: '0x',
+ });
+ });
+
+ it('should handle transaction error when useSendTransaction returns an error', () => {
+ vi.mocked(useSendTransaction).mockReturnValue({
+ code: 'SeMSeBC01', // Send module SendButton component 01 error
+ error: 'Transaction failed',
+ message: 'Error: Transaction failed',
+ });
+
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const token = {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: 4000,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleAddressSelection({
+ display: 'user.eth',
+ value: '0x1234',
+ });
+ result.current.handleTokenSelection(token);
+ result.current.handleCryptoAmountChange('1.0');
+ });
+
+ expect(result.current.lifecycleStatus.statusName).toBe('error');
+ expect((result.current.lifecycleStatus.statusData as APIError).code).toBe(
+ 'SeMSeBC01',
+ );
+ expect((result.current.lifecycleStatus.statusData as APIError).error).toBe(
+ 'Error building send transaction: Transaction failed',
+ );
+ expect(
+ (result.current.lifecycleStatus.statusData as APIError).message,
+ ).toBe('Error: Transaction failed');
+ });
+
+ it('should handle transaction error when useSendTransaction throws', () => {
+ vi.mocked(useSendTransaction).mockImplementation(() => {
+ throw new Error('Uncaught send transaction error');
+ });
+
+ const { result } = renderHook(() => useSendContext(), {
+ wrapper: SendProvider,
+ });
+
+ const token = {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ address: '' as const,
+ decimals: 18,
+ cryptoBalance: 200000000000000,
+ fiatBalance: 4000,
+ chainId: 8453,
+ image: '',
+ };
+
+ act(() => {
+ result.current.handleAddressSelection({
+ display: 'user.eth',
+ value: '0x1234',
+ });
+ result.current.handleTokenSelection(token);
+ result.current.handleCryptoAmountChange('1.0');
+ });
+
+ expect(result.current.lifecycleStatus.statusName).toBe('error');
+ expect((result.current.lifecycleStatus.statusData as APIError).code).toBe(
+ 'UNCAUGHT_SEND_TRANSACTION_ERROR',
+ );
+ expect((result.current.lifecycleStatus.statusData as APIError).error).toBe(
+ 'Error building send transaction: Uncaught send transaction error',
+ );
+ expect(
+ (result.current.lifecycleStatus.statusData as APIError).message,
+ ).toBe('Error: Uncaught send transaction error');
+ });
+});
diff --git a/src/wallet/components/wallet-advanced-send/components/SendProvider.tsx b/src/wallet/components/wallet-advanced-send/components/SendProvider.tsx
new file mode 100644
index 0000000000..7d4f1d6464
--- /dev/null
+++ b/src/wallet/components/wallet-advanced-send/components/SendProvider.tsx
@@ -0,0 +1,293 @@
+import type {
+ APIError,
+ PortfolioTokenWithFiatValue,
+ PriceQuoteToken,
+} from '@/api/types';
+import { useExchangeRate } from '@/internal/hooks/useExchangeRate';
+import { useLifecycleStatus } from '@/internal/hooks/useLifecycleStatus';
+import { useSendTransaction } from '@/internal/hooks/useSendTransaction';
+import { useValue } from '@/internal/hooks/useValue';
+import { truncateDecimalPlaces } from '@/internal/utils/truncateDecimalPlaces';
+import type { Call } from '@/transaction/types';
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from 'react';
+import { formatUnits } from 'viem';
+import { useWalletAdvancedContext } from '../../WalletAdvancedProvider';
+import type {
+ RecipientAddress,
+ SendContextType,
+ SendLifecycleStatus,
+ SendProviderReact,
+} from '../types';
+
+const emptyContext = {} as SendContextType;
+
+const SendContext = createContext(emptyContext);
+
+export function useSendContext() {
+ const sendContext = useContext(SendContext);
+ if (sendContext === emptyContext) {
+ throw new Error('useSendContext must be used within a SendProvider');
+ }
+ return sendContext;
+}
+
+export function SendProvider({ children }: SendProviderReact) {
+ const [isInitialized, setIsInitialized] = useState(false);
+
+ // state for ETH balance
+ const [ethBalance, setEthBalance] = useState(0);
+
+ // state for recipient address selection
+ const [selectedRecipientAddress, setSelectedRecipientAddress] =
+ useState({
+ display: '',
+ value: null,
+ });
+
+ // state for token selection
+ const [selectedToken, setSelectedToken] =
+ useState(null);
+ const [selectedInputType, setSelectedInputType] = useState<'fiat' | 'crypto'>(
+ 'crypto',
+ );
+ const [fiatAmount, setFiatAmount] = useState(null);
+ const [cryptoAmount, setCryptoAmount] = useState(null);
+ const [exchangeRate, setExchangeRate] = useState(0);
+ const [exchangeRateLoading, setExchangeRateLoading] =
+ useState(false);
+
+ // state for transaction data
+ const [callData, setCallData] = useState(null);
+
+ // lifecycle status
+ const [lifecycleStatus, updateLifecycleStatus] =
+ useLifecycleStatus({
+ statusName: 'init',
+ statusData: {
+ isMissingRequiredField: true,
+ },
+ });
+
+ // fetch & set ETH balance
+ const { tokenBalances } = useWalletAdvancedContext();
+ useEffect(() => {
+ const ethBalance = tokenBalances?.find((token) => token.address === '');
+ if (ethBalance && ethBalance.cryptoBalance > 0) {
+ setEthBalance(
+ Number(
+ formatUnits(BigInt(ethBalance.cryptoBalance), ethBalance.decimals),
+ ),
+ );
+ updateLifecycleStatus({
+ statusName: 'selectingAddress',
+ statusData: {
+ isMissingRequiredField: true,
+ },
+ });
+ } else {
+ updateLifecycleStatus({
+ statusName: 'fundingWallet',
+ statusData: {
+ isMissingRequiredField: true,
+ },
+ });
+ }
+ setIsInitialized(true);
+ }, [tokenBalances, updateLifecycleStatus]);
+
+ // fetch & set exchange rate
+ useEffect(() => {
+ if (!selectedToken) {
+ return;
+ }
+
+ const tokenSymbol = selectedToken.symbol;
+ const tokenAddress = selectedToken.address;
+ let tokenParam: PriceQuoteToken;
+
+ if (tokenSymbol === 'ETH') {
+ tokenParam = 'ETH' as const;
+ } else if (tokenAddress !== '') {
+ tokenParam = tokenAddress;
+ } else {
+ return;
+ }
+
+ useExchangeRate({
+ token: tokenParam,
+ selectedInputType,
+ setExchangeRate,
+ setExchangeRateLoading,
+ });
+ }, [selectedToken, selectedInputType]);
+
+ // handlers
+ const handleRecipientInputChange = useCallback(() => {
+ setSelectedRecipientAddress({
+ display: '',
+ value: null,
+ });
+ updateLifecycleStatus({
+ statusName: 'selectingAddress',
+ statusData: {
+ isMissingRequiredField: true,
+ },
+ });
+ }, [updateLifecycleStatus]);
+
+ const handleAddressSelection = useCallback(
+ async (selection: RecipientAddress) => {
+ setSelectedRecipientAddress(selection);
+ updateLifecycleStatus({
+ statusName: 'selectingToken',
+ statusData: {
+ isMissingRequiredField: true,
+ },
+ });
+ },
+ [updateLifecycleStatus],
+ );
+
+ const handleTokenSelection = useCallback(
+ (token: PortfolioTokenWithFiatValue) => {
+ setSelectedToken(token);
+ updateLifecycleStatus({
+ statusName: 'amountChange',
+ statusData: {
+ isMissingRequiredField: true,
+ sufficientBalance: false,
+ },
+ });
+ },
+ [updateLifecycleStatus],
+ );
+
+ const handleResetTokenSelection = useCallback(() => {
+ setSelectedToken(null);
+ setFiatAmount(null);
+ setCryptoAmount(null);
+ setExchangeRate(0);
+ updateLifecycleStatus({
+ statusName: 'selectingToken',
+ statusData: {
+ isMissingRequiredField: true,
+ },
+ });
+ }, [updateLifecycleStatus]);
+
+ const handleFiatAmountChange = useCallback(
+ (value: string) => {
+ setFiatAmount(value);
+ updateLifecycleStatus({
+ statusName: 'amountChange',
+ statusData: {
+ isMissingRequiredField: true,
+ sufficientBalance:
+ Number(value) <= Number(selectedToken?.fiatBalance),
+ },
+ });
+ },
+ [updateLifecycleStatus, selectedToken],
+ );
+
+ const handleCryptoAmountChange = useCallback(
+ (value: string) => {
+ const truncatedValue = truncateDecimalPlaces(value, 8);
+ setCryptoAmount(truncatedValue);
+ updateLifecycleStatus({
+ statusName: 'amountChange',
+ statusData: {
+ isMissingRequiredField: true,
+ sufficientBalance:
+ Number(value) <=
+ Number(
+ formatUnits(
+ BigInt(selectedToken?.cryptoBalance ?? 0),
+ selectedToken?.decimals ?? 0,
+ ),
+ ),
+ },
+ });
+ },
+ [updateLifecycleStatus, selectedToken],
+ );
+
+ const handleTransactionError = useCallback(
+ (error: APIError) => {
+ updateLifecycleStatus({
+ statusName: 'error',
+ statusData: {
+ code: error.code,
+ error: `Error building send transaction: ${error.error}`,
+ message: error.message,
+ },
+ });
+ },
+ [updateLifecycleStatus],
+ );
+
+ const fetchTransactionData = useCallback(() => {
+ if (!selectedRecipientAddress.value || !selectedToken || !cryptoAmount) {
+ return;
+ }
+
+ try {
+ setCallData(null);
+ const calls = useSendTransaction({
+ recipientAddress: selectedRecipientAddress.value,
+ token: selectedToken,
+ amount: cryptoAmount,
+ });
+ if ('error' in calls) {
+ handleTransactionError(calls);
+ } else {
+ setCallData(calls);
+ }
+ } catch (error) {
+ handleTransactionError({
+ code: 'UNCAUGHT_SEND_TRANSACTION_ERROR',
+ error: 'Uncaught send transaction error',
+ message: String(error),
+ });
+ }
+ }, [
+ selectedRecipientAddress,
+ selectedToken,
+ cryptoAmount,
+ handleTransactionError,
+ ]);
+
+ useEffect(() => {
+ fetchTransactionData();
+ }, [fetchTransactionData]);
+
+ const value = useValue({
+ isInitialized,
+ lifecycleStatus,
+ updateLifecycleStatus,
+ ethBalance,
+ selectedRecipientAddress,
+ handleAddressSelection,
+ selectedToken,
+ handleRecipientInputChange,
+ handleTokenSelection,
+ handleResetTokenSelection,
+ fiatAmount,
+ handleFiatAmountChange,
+ cryptoAmount,
+ handleCryptoAmountChange,
+ exchangeRate,
+ exchangeRateLoading,
+ selectedInputType,
+ setSelectedInputType,
+ callData,
+ });
+
+ return {children};
+}
diff --git a/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.test.tsx b/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.test.tsx
index 8358529ee2..85a72061b3 100644
--- a/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.test.tsx
+++ b/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.test.tsx
@@ -2,6 +2,7 @@ import type { PortfolioTokenWithFiatValue } from '@/api/types';
import { fireEvent, render, screen } from '@testing-library/react';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { useWalletAdvancedContext } from '../../WalletAdvancedProvider';
+import { useSendContext } from './SendProvider';
import { SendTokenSelector } from './SendTokenSelector';
// Mock the context hook
@@ -9,6 +10,10 @@ vi.mock('../../WalletAdvancedProvider', () => ({
useWalletAdvancedContext: vi.fn(),
}));
+vi.mock('./SendProvider', () => ({
+ useSendContext: vi.fn(),
+}));
+
const mockTokenBalances: PortfolioTokenWithFiatValue[] = [
{
address: '0x1230000000000000000000000000000000000000',
@@ -32,25 +37,26 @@ const mockTokenBalances: PortfolioTokenWithFiatValue[] = [
},
];
-describe('SendTokenSelector', () => {
- const defaultProps = {
- selectedToken: null,
- handleTokenSelection: vi.fn(),
- handleResetTokenSelection: vi.fn(),
- setSelectedInputType: vi.fn(),
- handleCryptoAmountChange: vi.fn(),
- handleFiatAmountChange: vi.fn(),
- };
+const defaultContext = {
+ selectedToken: null,
+ handleTokenSelection: vi.fn(),
+ handleResetTokenSelection: vi.fn(),
+ setSelectedInputType: vi.fn(),
+ handleCryptoAmountChange: vi.fn(),
+ handleFiatAmountChange: vi.fn(),
+};
+describe('SendTokenSelector', () => {
beforeEach(() => {
vi.clearAllMocks();
(useWalletAdvancedContext as Mock).mockReturnValue({
tokenBalances: mockTokenBalances,
});
+ (useSendContext as Mock).mockReturnValue(defaultContext);
});
it('renders token selection list when no token is selected', () => {
- render();
+ render();
expect(screen.getByText('Select a token')).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(
@@ -59,54 +65,54 @@ describe('SendTokenSelector', () => {
});
it('calls handleTokenSelection when a token is clicked from the list', () => {
- render();
+ render();
fireEvent.click(screen.getAllByTestId('ockTokenBalanceButton')[0]);
- expect(defaultProps.handleTokenSelection).toHaveBeenCalledWith(
+ expect(defaultContext.handleTokenSelection).toHaveBeenCalledWith(
mockTokenBalances[0],
);
});
it('renders selected token with max button when token is selected', () => {
- render(
- ,
- );
+ (useSendContext as Mock).mockReturnValue({
+ ...defaultContext,
+ selectedToken: mockTokenBalances[0],
+ });
+
+ render();
expect(screen.getByText('Test Token')).toBeInTheDocument();
expect(screen.getByText(/0\.000 TEST available/)).toBeInTheDocument();
});
it('handles max button click correctly', () => {
- render(
- ,
- );
+ (useSendContext as Mock).mockReturnValue({
+ ...defaultContext,
+ selectedToken: mockTokenBalances[0],
+ });
+
+ render();
const maxButton = screen.getByRole('button', { name: 'Use max' });
fireEvent.click(maxButton);
- expect(defaultProps.setSelectedInputType).toHaveBeenCalledWith('crypto');
- expect(defaultProps.handleFiatAmountChange).toHaveBeenCalledWith('100');
- expect(defaultProps.handleCryptoAmountChange).toHaveBeenCalledWith(
+ expect(defaultContext.setSelectedInputType).toHaveBeenCalledWith('crypto');
+ expect(defaultContext.handleFiatAmountChange).toHaveBeenCalledWith('100');
+ expect(defaultContext.handleCryptoAmountChange).toHaveBeenCalledWith(
'0.000000001',
);
});
it('calls handleResetTokenSelection when selected token is clicked', () => {
- render(
- ,
- );
+ (useSendContext as Mock).mockReturnValue({
+ ...defaultContext,
+ selectedToken: mockTokenBalances[0],
+ });
+
+ render();
fireEvent.click(screen.getByTestId('ockTokenBalanceButton'));
- expect(defaultProps.handleResetTokenSelection).toHaveBeenCalled();
+ expect(defaultContext.handleResetTokenSelection).toHaveBeenCalled();
});
it('handles empty tokenBalances gracefully', () => {
@@ -114,7 +120,7 @@ describe('SendTokenSelector', () => {
tokenBalances: [],
});
- render();
+ render();
expect(screen.getByText('Select a token')).toBeInTheDocument();
});
@@ -124,19 +130,18 @@ describe('SendTokenSelector', () => {
};
const { rerender } = render(
- ,
+ ,
);
const buttons = screen.getAllByTestId('ockTokenBalanceButton');
expect(buttons[0]).toHaveClass(customClassNames.container);
expect(buttons[1]).toHaveClass(customClassNames.container);
- rerender(
- ,
- );
+ (useSendContext as Mock).mockReturnValue({
+ ...defaultContext,
+ selectedToken: mockTokenBalances[0],
+ });
+
+ rerender();
const button = screen.getByTestId('ockTokenBalanceButton');
expect(button).toHaveClass(customClassNames.container);
});
diff --git a/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.tsx b/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.tsx
index b2e4e5c69c..2b00cd0d74 100644
--- a/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.tsx
+++ b/src/wallet/components/wallet-advanced-send/components/SendTokenSelector.tsx
@@ -4,18 +4,28 @@ import { border, cn, color, pressable, text } from '@/styles/theme';
import { TokenBalance } from '@/token';
import { formatUnits } from 'viem';
import { useWalletAdvancedContext } from '../../WalletAdvancedProvider';
-import type { SendTokenSelectorProps } from '../types';
+import { useSendContext } from './SendProvider';
-export function SendTokenSelector({
- selectedToken,
- handleTokenSelection,
- handleResetTokenSelection,
- setSelectedInputType,
- handleCryptoAmountChange,
- handleFiatAmountChange,
- classNames,
-}: SendTokenSelectorProps) {
+type SendTokenSelectorProps = {
+ classNames?: {
+ container?: string;
+ tokenName?: string;
+ tokenValue?: string;
+ fiatValue?: string;
+ action?: string;
+ };
+};
+
+export function SendTokenSelector({ classNames }: SendTokenSelectorProps) {
const { tokenBalances } = useWalletAdvancedContext();
+ const {
+ selectedToken,
+ handleTokenSelection,
+ handleResetTokenSelection,
+ setSelectedInputType,
+ handleCryptoAmountChange,
+ handleFiatAmountChange,
+ } = useSendContext();
if (!selectedToken) {
return (
diff --git a/src/wallet/components/wallet-advanced-send/constants.ts b/src/wallet/components/wallet-advanced-send/constants.ts
new file mode 100644
index 0000000000..b784b5942d
--- /dev/null
+++ b/src/wallet/components/wallet-advanced-send/constants.ts
@@ -0,0 +1 @@
+export const ETH_REQUIRED_FOR_SEND = 0.000001;
diff --git a/src/wallet/components/wallet-advanced-send/types.ts b/src/wallet/components/wallet-advanced-send/types.ts
index 86796fb1e5..c7a486f661 100644
--- a/src/wallet/components/wallet-advanced-send/types.ts
+++ b/src/wallet/components/wallet-advanced-send/types.ts
@@ -1,9 +1,14 @@
import type { Dispatch, ReactNode, SetStateAction } from 'react';
-import type { Address, TransactionReceipt } from 'viem';
+import type { Address, Chain, TransactionReceipt } from 'viem';
import type { APIError, PortfolioTokenWithFiatValue } from '../../../api/types';
import type { LifecycleStatusUpdate } from '../../../internal/types';
import type { Call } from '../../../transaction/types';
+export type SendReact = {
+ children?: ReactNode;
+ className?: string;
+};
+
export type SendProviderReact = {
children: ReactNode;
};
@@ -21,7 +26,7 @@ export type SendContextType = {
// Sender Context
/** The balance of the sender's ETH wallet */
- ethBalance: number | undefined;
+ ethBalance: number;
// Recipient Address Context
/** The selected recipient address */
@@ -122,47 +127,27 @@ export type SendLifecycleStatus =
statusData: APIError;
};
-export type SendAmountInputProps = {
- className?: string;
- textClassName?: string;
-} & Pick<
- SendContextType,
- | 'selectedToken'
- | 'cryptoAmount'
- | 'handleCryptoAmountChange'
- | 'fiatAmount'
- | 'handleFiatAmountChange'
- | 'selectedInputType'
- | 'setSelectedInputType'
- | 'exchangeRate'
- | 'exchangeRateLoading'
->;
-
-export type SendFundingWalletProps = {
- onError?: () => void;
- onStatus?: () => void;
- onSuccess?: () => void;
+export type SendAddressInputProps = {
+ selectedRecipientAddress: RecipientAddress;
+ recipientInput: string;
+ setRecipientInput: Dispatch>;
+ setValidatedInput: Dispatch>;
+ handleRecipientInputChange: () => void;
classNames?: {
container?: string;
- subtitle?: string;
- fundCard?: string;
+ label?: string;
+ input?: string;
};
};
-export type SendTokenSelectorProps = {
+export type SendAddressSelectorProps = {
+ address: Address | null;
+ senderChain: Chain | null | undefined;
+ handleClick: () => Promise;
classNames?: {
container?: string;
- tokenName?: string;
- tokenValue?: string;
- fiatValue?: string;
- action?: string;
+ avatar?: string;
+ name?: string;
+ address?: string;
};
-} & Pick<
- SendContextType,
- | 'selectedToken'
- | 'handleTokenSelection'
- | 'handleResetTokenSelection'
- | 'setSelectedInputType'
- | 'handleCryptoAmountChange'
- | 'handleFiatAmountChange'
->;
+};
diff --git a/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.test.ts b/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.test.ts
deleted file mode 100644
index e108d75322..0000000000
--- a/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { PortfolioTokenWithFiatValue } from '@/api/types';
-import { describe, expect, it } from 'vitest';
-import { getDefaultSendButtonLabel } from './getDefaultSendButtonLabel';
-
-describe('getDefaultSendButtonLabel', () => {
- const mockToken = {
- address: '0x1230000000000000000000000000000000000000',
- symbol: 'TEST',
- name: 'Test Token',
- decimals: 18,
- cryptoBalance: 1000000000000000,
- fiatBalance: 100,
- image: 'test.png',
- chainId: 8453,
- } as PortfolioTokenWithFiatValue;
-
- it('returns "Input amount" when cryptoAmount is null', () => {
- expect(getDefaultSendButtonLabel(null, mockToken)).toBe('Input amount');
- });
-
- it('returns "Input amount" when cryptoAmount is empty string', () => {
- expect(getDefaultSendButtonLabel('', mockToken)).toBe('Input amount');
- });
-
- it('returns "Select token" when token is null', () => {
- expect(getDefaultSendButtonLabel('1.0', null)).toBe('Select token');
- });
-
- it('returns "Insufficient balance" when amount exceeds balance', () => {
- expect(getDefaultSendButtonLabel('2.0', mockToken)).toBe(
- 'Insufficient balance',
- );
- });
-
- it('returns "Continue" when amount is valid and within balance', () => {
- expect(getDefaultSendButtonLabel('0.0001', mockToken)).toBe('Continue');
- });
-
- it('returns "Continue" when amount equals balance exactly', () => {
- expect(getDefaultSendButtonLabel('0.001', mockToken)).toBe('Continue');
- });
-});
diff --git a/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.ts b/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.ts
deleted file mode 100644
index 883b20bf81..0000000000
--- a/src/wallet/components/wallet-advanced-send/utils/getDefaultSendButtonLabel.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { PortfolioTokenWithFiatValue } from '@/api/types';
-import { parseUnits } from 'viem';
-
-export function getDefaultSendButtonLabel(
- cryptoAmount: string | null,
- selectedToken: PortfolioTokenWithFiatValue | null,
-) {
- if (!cryptoAmount) {
- return 'Input amount';
- }
-
- if (!selectedToken) {
- return 'Select token';
- }
-
- if (
- parseUnits(cryptoAmount, selectedToken.decimals) >
- selectedToken.cryptoBalance
- ) {
- return 'Insufficient balance';
- }
-
- return 'Continue';
-}
diff --git a/src/wallet/types.ts b/src/wallet/types.ts
index c46b1a3af1..85e8ea8cbb 100644
--- a/src/wallet/types.ts
+++ b/src/wallet/types.ts
@@ -214,7 +214,7 @@ export type WalletAdvancedReact = {
};
};
-export type WalletAdvancedFeature = 'qr' | 'swap';
+export type WalletAdvancedFeature = 'qr' | 'swap' | 'send';
/**
* Note: exported as public Type