diff --git a/src/identity/utils/getAddress.test.ts b/src/identity/utils/getAddress.test.ts index 9372f4a1e8..b403be77ef 100644 --- a/src/identity/utils/getAddress.test.ts +++ b/src/identity/utils/getAddress.test.ts @@ -1,6 +1,6 @@ import { publicClient } from '@/core/network/client'; import { getChainPublicClient } from '@/core/network/getChainPublicClient'; -import { base, baseSepolia, mainnet } from 'viem/chains'; +import { base, baseSepolia, mainnet, optimism } from 'viem/chains'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { getAddress } from './getAddress'; @@ -55,4 +55,12 @@ describe('getAddress', () => { expect(mockGetEnsAddress).toHaveBeenCalledWith({ name }); expect(getChainPublicClient).toHaveBeenCalledWith(mainnet); }); + + it('should throw an error on unsupported chain', async () => { + await expect( + getAddress({ name: 'test.ens', chain: optimism }), + ).rejects.toBe( + 'ChainId not supported, name resolution is only supported on Ethereum and Base.', + ); + }); }); diff --git a/src/identity/utils/getAddress.ts b/src/identity/utils/getAddress.ts index 7cd544fd1f..b30a0ecdac 100644 --- a/src/identity/utils/getAddress.ts +++ b/src/identity/utils/getAddress.ts @@ -1,5 +1,9 @@ import { getChainPublicClient } from '@/core/network/getChainPublicClient'; +import { isBase } from '@/core/utils/isBase'; +import { isEthereum } from '@/core/utils/isEthereum'; +import { RESOLVER_ADDRESSES_BY_CHAIN_ID } from '@/identity/constants'; import type { GetAddress, GetAddressReturnType } from '@/identity/types'; +import { isBasename } from '@/identity/utils/isBasename'; import { mainnet } from 'viem/chains'; /** @@ -9,10 +13,23 @@ export const getAddress = async ({ name, chain = mainnet, }: GetAddress): Promise => { + const chainIsBase = isBase({ chainId: chain.id }); + const chainIsEthereum = isEthereum({ chainId: chain.id }); + const chainSupportsUniversalResolver = chainIsEthereum || chainIsBase; + + if (!chainSupportsUniversalResolver) { + return Promise.reject( + 'ChainId not supported, name resolution is only supported on Ethereum and Base.', + ); + } + const client = getChainPublicClient(chain); // Gets address for ENS name. const address = await client.getEnsAddress({ name, + universalResolverAddress: isBasename(name) + ? RESOLVER_ADDRESSES_BY_CHAIN_ID[chain.id] + : undefined, }); return address ?? null; }; diff --git a/src/identity/utils/getName.ts b/src/identity/utils/getName.ts index 928b079e1b..b772f834e1 100644 --- a/src/identity/utils/getName.ts +++ b/src/identity/utils/getName.ts @@ -26,7 +26,7 @@ export const getName = async ({ ); } - let client = getChainPublicClient(chain); + const client = getChainPublicClient(chain); if (chainIsBase) { const addressReverseNode = convertReverseNodeToBytes(address, base.id); @@ -45,8 +45,6 @@ export const getName = async ({ } } - // Default to mainnet - client = getChainPublicClient(mainnet); // ENS username const ensName = await client.getEnsName({ address, diff --git a/src/identity/utils/isEns.test.ts b/src/identity/utils/isEns.test.ts new file mode 100644 index 0000000000..c94a3f7c1c --- /dev/null +++ b/src/identity/utils/isEns.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { isEns } from './isEns'; + +describe('isEns', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('Returns true for mainnet names', async () => { + expect(isEns('shrek.eth')).toBe(true); + expect(isEns('shrek.optimisim.eth')).toBe(true); + expect(isEns('shrek.baaaaaes.eth')).toBe(true); + }); + + it('Returns true for mainnet sepolia names', async () => { + expect(isEns('shrek.test.eth')).toBe(true); + }); + + it('Returns false for basenames', async () => { + expect(isEns('shrek.base.eth')).toBe(false); + expect(isEns('shrek.basetest.eth')).toBe(false); + }); + + it('Returns false for any other name', async () => { + expect(isEns('shrek.optimisim')).toBe(false); + expect(isEns('shrek.baaaaaes')).toBe(false); + expect(isEns('shrek')).toBe(false); + expect(isEns('shrek.sol')).toBe(false); + }); +}); diff --git a/src/identity/utils/isEns.ts b/src/identity/utils/isEns.ts new file mode 100644 index 0000000000..5ace71e9c0 --- /dev/null +++ b/src/identity/utils/isEns.ts @@ -0,0 +1,9 @@ +export const isEns = (username: string) => { + if (username.endsWith('.base.eth') || username.endsWith('.basetest.eth')) { + return false; + } + if (username.endsWith('.eth') || username.endsWith('.test.eth')) { + return true; + } + return false; +}; diff --git a/src/internal/hooks/useAmountInput.test.tsx b/src/internal/hooks/useAmountInput.test.tsx new file mode 100644 index 0000000000..81f9838380 --- /dev/null +++ b/src/internal/hooks/useAmountInput.test.tsx @@ -0,0 +1,104 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAmountInput } from './useAmountInput'; + +describe('useAmountInput', () => { + const defaultProps = { + setFiatAmount: vi.fn(), + setCryptoAmount: vi.fn(), + selectedInputType: 'fiat' as const, + exchangeRate: '2', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('handleFiatChange', () => { + it('should handle fiat input and calculate crypto amount', () => { + const { result } = renderHook(() => useAmountInput(defaultProps)); + + act(() => { + result.current.handleFiatChange('100.456'); + }); + + expect(defaultProps.setFiatAmount).toHaveBeenCalledWith('100.46'); + expect(defaultProps.setCryptoAmount).toHaveBeenCalledWith('200.92'); + }); + + it('should set empty crypto amount when fiat is zero', () => { + const { result } = renderHook(() => useAmountInput(defaultProps)); + + act(() => { + result.current.handleFiatChange('0'); + }); + + expect(defaultProps.setCryptoAmount).toHaveBeenCalledWith(''); + }); + }); + + describe('handleCryptoChange', () => { + it('should handle crypto input and calculate fiat amount', () => { + const { result } = renderHook(() => useAmountInput(defaultProps)); + + act(() => { + result.current.handleCryptoChange('200.12345678'); + }); + + expect(defaultProps.setCryptoAmount).toHaveBeenCalledWith('200.12345678'); + expect(defaultProps.setFiatAmount).toHaveBeenCalledWith('100.06'); + }); + + it('should set empty fiat amount when crypto calculation is zero', () => { + const { result } = renderHook(() => useAmountInput(defaultProps)); + + act(() => { + result.current.handleCryptoChange('0'); + }); + + expect(defaultProps.setFiatAmount).toHaveBeenCalledWith(''); + }); + }); + + describe('handleChange', () => { + it('should call handleFiatChange when selectedInputType is fiat', () => { + const { result } = renderHook(() => useAmountInput(defaultProps)); + const onChange = vi.fn(); + + act(() => { + result.current.handleChange('100', onChange); + }); + + expect(defaultProps.setFiatAmount).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith('100'); + }); + + it('should call handleCryptoChange when selectedInputType is crypto', () => { + const { result } = renderHook(() => + useAmountInput({ + ...defaultProps, + selectedInputType: 'crypto', + }), + ); + const onChange = vi.fn(); + + act(() => { + result.current.handleChange('100', onChange); + }); + + expect(defaultProps.setCryptoAmount).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith('100'); + }); + + it('should work without optional onChange callback', () => { + const { result } = renderHook(() => useAmountInput(defaultProps)); + + act(() => { + result.current.handleChange('100'); + }); + + expect(defaultProps.setFiatAmount).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/internal/hooks/useAmountInput.tsx b/src/internal/hooks/useAmountInput.tsx new file mode 100644 index 0000000000..ae30eb2fbc --- /dev/null +++ b/src/internal/hooks/useAmountInput.tsx @@ -0,0 +1,67 @@ +import { truncateDecimalPlaces } from '@/internal/utils/truncateDecimalPlaces'; +import { useCallback, useMemo } from 'react'; + +type UseAmountInputParams = { + setFiatAmount: (value: string) => void; + setCryptoAmount: (value: string) => void; + selectedInputType: 'fiat' | 'crypto'; + exchangeRate: string; +}; + +export const useAmountInput = ({ + setFiatAmount, + setCryptoAmount, + selectedInputType, + exchangeRate, +}: UseAmountInputParams) => { + const handleFiatChange = useCallback( + (value: string) => { + const fiatValue = truncateDecimalPlaces(value, 2); + setFiatAmount(fiatValue); + + const calculatedCryptoValue = String( + Number(fiatValue) * Number(exchangeRate), + ); + const resultCryptoValue = truncateDecimalPlaces(calculatedCryptoValue, 8); + setCryptoAmount(calculatedCryptoValue === '0' ? '' : resultCryptoValue); + }, + [exchangeRate, setFiatAmount, setCryptoAmount], + ); + + const handleCryptoChange = useCallback( + (value: string) => { + const truncatedValue = truncateDecimalPlaces(value, 8); + setCryptoAmount(truncatedValue); + + const calculatedFiatValue = String( + Number(truncatedValue) / Number(exchangeRate), + ); + + const resultFiatValue = truncateDecimalPlaces(calculatedFiatValue, 2); + setFiatAmount(resultFiatValue === '0' ? '' : resultFiatValue); + }, + [exchangeRate, setFiatAmount, setCryptoAmount], + ); + + const handleChange = useCallback( + (value: string, onChange?: (value: string) => void) => { + if (selectedInputType === 'fiat') { + handleFiatChange(value); + } else { + handleCryptoChange(value); + } + + onChange?.(value); + }, + [handleFiatChange, handleCryptoChange, selectedInputType], + ); + + return useMemo( + () => ({ + handleChange, + handleFiatChange, + handleCryptoChange, + }), + [handleChange, handleFiatChange, handleCryptoChange], + ); +}; diff --git a/src/internal/hooks/useExchangeRate.test.tsx b/src/internal/hooks/useExchangeRate.test.tsx new file mode 100644 index 0000000000..66a99ade24 --- /dev/null +++ b/src/internal/hooks/useExchangeRate.test.tsx @@ -0,0 +1,177 @@ +import { getSwapQuote } from '@/api'; +import type { Token } from '@/token'; +import { ethToken, usdcToken } from '@/token/constants'; +import { renderHook, waitFor } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useExchangeRate } from './useExchangeRate'; + +vi.mock('@/api', () => ({ + getSwapQuote: vi.fn(), +})); + +describe('useExchangeRate', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should return undefined without calling setExchangeRate if a token is not provided', async () => { + const mockSetExchangeRate = vi.fn(); + const { result } = renderHook(() => + useExchangeRate({ + token: undefined as unknown as Token, + selectedInputType: 'crypto', + setExchangeRate: mockSetExchangeRate, + setExchangeRateLoading: vi.fn(), + }), + ); + + const resolvedValue = await result.current; + expect(resolvedValue).toBeUndefined(); + expect(mockSetExchangeRate).not.toHaveBeenCalled(); + }); + + it('should return 1 if a token is usdc', async () => { + const mockSetExchangeRate = vi.fn(); + renderHook(() => + useExchangeRate({ + token: usdcToken, + selectedInputType: 'crypto', + setExchangeRate: mockSetExchangeRate, + setExchangeRateLoading: vi.fn(), + }), + ); + + expect(mockSetExchangeRate).toHaveBeenCalledWith(1); + }); + + it('should set the correct exchange rate when the selected input type is crypto', async () => { + const mockSetExchangeRate = vi.fn(); + const mockSetExchangeRateLoading = vi.fn(); + + (getSwapQuote as Mock).mockResolvedValue({ + fromAmountUSD: '200', + toAmount: '100000000', + to: { + decimals: 18, + }, + }); + + renderHook(() => + useExchangeRate({ + token: ethToken, + selectedInputType: 'crypto', + setExchangeRate: mockSetExchangeRate, + setExchangeRateLoading: mockSetExchangeRateLoading, + }), + ); + + await waitFor(() => { + expect(mockSetExchangeRate).toHaveBeenCalledWith(1 / 200); + }); + }); + + it('should set the correct the exchange rate when the selected input type is fiat', async () => { + const mockSetExchangeRate = vi.fn(); + const mockSetExchangeRateLoading = vi.fn(); + + (getSwapQuote as Mock).mockResolvedValue({ + fromAmountUSD: '200', + toAmount: '100000000', + to: { + decimals: 18, + }, + }); + + renderHook(() => + useExchangeRate({ + token: ethToken, + selectedInputType: 'fiat', + setExchangeRate: mockSetExchangeRate, + setExchangeRateLoading: mockSetExchangeRateLoading, + }), + ); + + await waitFor(() => { + expect(mockSetExchangeRate).toHaveBeenCalledWith(100000000 / 10 ** 18); + }); + }); + + it('should log an error and not set the exchange rate when the api call returns an error', async () => { + const mockSetExchangeRate = vi.fn(); + const mockSetExchangeRateLoading = vi.fn(); + const consoleSpy = vi.spyOn(console, 'error'); + + (getSwapQuote as Mock).mockResolvedValue({ + error: 'test error', + }); + + renderHook(() => + useExchangeRate({ + token: ethToken, + selectedInputType: 'fiat', + setExchangeRate: mockSetExchangeRate, + setExchangeRateLoading: mockSetExchangeRateLoading, + }), + ); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Error fetching exchange rate:', + 'test error', + ); + expect(mockSetExchangeRate).not.toHaveBeenCalled(); + }); + }); + + it('should log an error and not set the exchange rate when the api fails', async () => { + const mockSetExchangeRate = vi.fn(); + const mockSetExchangeRateLoading = vi.fn(); + const consoleSpy = vi.spyOn(console, 'error'); + + (getSwapQuote as Mock).mockRejectedValue(new Error('test error')); + + renderHook(() => + useExchangeRate({ + token: ethToken, + selectedInputType: 'fiat', + setExchangeRate: mockSetExchangeRate, + setExchangeRateLoading: mockSetExchangeRateLoading, + }), + ); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Uncaught error fetching exchange rate:', + expect.any(Error), + ); + expect(mockSetExchangeRate).not.toHaveBeenCalled(); + }); + }); + + it('should set and unset loading state', async () => { + const mockSetExchangeRate = vi.fn(); + const mockSetExchangeRateLoading = vi.fn(); + + (getSwapQuote as Mock).mockResolvedValue({ + fromAmountUSD: '1', + toAmount: '1', + to: { + decimals: 18, + }, + }); + + renderHook(() => + useExchangeRate({ + token: ethToken, + selectedInputType: 'crypto', + setExchangeRate: mockSetExchangeRate, + setExchangeRateLoading: mockSetExchangeRateLoading, + }), + ); + + await waitFor(() => { + expect(mockSetExchangeRateLoading).toHaveBeenCalledWith(true); + expect(mockSetExchangeRateLoading).toHaveBeenLastCalledWith(false); + }); + }); +}); diff --git a/src/internal/hooks/useExchangeRate.tsx b/src/internal/hooks/useExchangeRate.tsx new file mode 100644 index 0000000000..fe48f27915 --- /dev/null +++ b/src/internal/hooks/useExchangeRate.tsx @@ -0,0 +1,55 @@ +import { getSwapQuote } from '@/api'; +import { isApiError } from '@/internal/utils/isApiResponseError'; +import type { Token } from '@/token'; +import { usdcToken } from '@/token/constants'; +import type { Dispatch, SetStateAction } from 'react'; + +type UseExchangeRateParams = { + token: Token; + selectedInputType: 'crypto' | 'fiat'; + setExchangeRate: Dispatch>; + setExchangeRateLoading?: Dispatch>; +}; + +export async function useExchangeRate({ + token, + selectedInputType, + setExchangeRate, + setExchangeRateLoading, +}: UseExchangeRateParams) { + if (!token) { + return; + } + + if (token.address === usdcToken.address) { + setExchangeRate(1); + return; + } + + setExchangeRateLoading?.(true); + + const fromToken = selectedInputType === 'crypto' ? token : usdcToken; + const toToken = selectedInputType === 'crypto' ? usdcToken : token; + + try { + const response = await getSwapQuote({ + amount: '1', // hardcoded amount of 1 because we only need the exchange rate + from: fromToken, + to: toToken, + useAggregator: false, + }); + if (isApiError(response)) { + console.error('Error fetching exchange rate:', response.error); + return; + } + const rate = + selectedInputType === 'crypto' + ? 1 / Number(response.fromAmountUSD) + : Number(response.toAmount) / 10 ** response.to.decimals; + setExchangeRate(rate); + } catch (error) { + console.error('Uncaught error fetching exchange rate:', error); + } finally { + setExchangeRateLoading?.(false); + } +} diff --git a/src/internal/utils/getValidatedAddress.test.tsx b/src/internal/utils/getValidatedAddress.test.tsx new file mode 100644 index 0000000000..fd709572d2 --- /dev/null +++ b/src/internal/utils/getValidatedAddress.test.tsx @@ -0,0 +1,82 @@ +import { getAddress } from '@/identity/utils/getAddress'; +import { isBasename } from '@/identity/utils/isBasename'; +import { isEns } from '@/identity/utils/isEns'; +import { isAddress } from 'viem'; +import { base, mainnet } from 'viem/chains'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getValidatedAddress } from './getValidatedAddress'; + +vi.mock('viem', () => { + return { + isAddress: vi.fn(), + }; +}); + +vi.mock('../../identity/utils/isBasename', () => { + return { + isBasename: vi.fn(), + }; +}); + +vi.mock('../../identity/utils/isEns', () => { + return { + isEns: vi.fn(), + }; +}); + +vi.mock('../../identity/utils/getAddress', () => { + return { + getAddress: vi.fn(), + }; +}); + +describe('getValidatedAddress', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return the input if it is a valid address', async () => { + const input = '0x1234'; + vi.mocked(isAddress).mockReturnValue(true); + expect(await getValidatedAddress(input)).toBe(input); + }); + + it('should return an address if the input is a valid basename', async () => { + const input = 'test.base.eth'; + const expectedAddress = '0x1234'; + vi.mocked(isBasename).mockReturnValue(true); + vi.mocked(getAddress).mockResolvedValue(expectedAddress); + + const testAddress = await getValidatedAddress(input); + + expect(getAddress).toHaveBeenCalledWith({ + name: input, + chain: base, + }); + expect(testAddress).toBe(expectedAddress); + }); + + it('should return an address if the input is a valid ens name', async () => { + const input = 'blahblah'; + const expectedAddress = '0xABCD'; + vi.mocked(isBasename).mockReturnValue(false); + vi.mocked(isEns).mockReturnValue(true); + vi.mocked(getAddress).mockResolvedValue(expectedAddress); + + const testAddress = await getValidatedAddress(input); + + expect(getAddress).toHaveBeenCalledWith({ + name: input, + chain: mainnet, + }); + expect(testAddress).toBe(expectedAddress); + }); + + it('should return null if the input is not a valid address, basename, or ens name', async () => { + const input = 'invalid'; + vi.mocked(isBasename).mockReturnValue(false); + vi.mocked(isEns).mockReturnValue(false); + vi.mocked(isAddress).mockReturnValue(false); + expect(await getValidatedAddress(input)).toBeNull(); + }); +}); diff --git a/src/internal/utils/getValidatedAddress.ts b/src/internal/utils/getValidatedAddress.ts new file mode 100644 index 0000000000..d5c37feb9d --- /dev/null +++ b/src/internal/utils/getValidatedAddress.ts @@ -0,0 +1,22 @@ +import { getAddress } from '@/identity/utils/getAddress'; +import { isBasename } from '@/identity/utils/isBasename'; +import { isEns } from '@/identity/utils/isEns'; +import { isAddress } from 'viem'; +import { base, mainnet } from 'viem/chains'; + +export async function getValidatedAddress(input: string) { + const inputIsBasename = isBasename(input); + const inputIsEns = isEns(input); + if (inputIsBasename || inputIsEns) { + const address = await getAddress({ + name: input, + chain: inputIsBasename ? base : mainnet, + }); + if (address) { + return address; + } + } else if (isAddress(input, { strict: false })) { + return input; + } + return null; +} diff --git a/src/internal/utils/truncateDecimalPlaces.test.ts b/src/internal/utils/truncateDecimalPlaces.test.ts new file mode 100644 index 0000000000..79cd8598fa --- /dev/null +++ b/src/internal/utils/truncateDecimalPlaces.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { truncateDecimalPlaces } from './truncateDecimalPlaces'; + +describe('truncateDecimalPlaces', () => { + it('handles string inputs', () => { + expect(truncateDecimalPlaces('123.456', 2)).toBe('123.46'); + expect(truncateDecimalPlaces('0.123456', 4)).toBe('0.1235'); + expect(truncateDecimalPlaces('100', 2)).toBe('100'); + }); + + it('handles number inputs', () => { + expect(truncateDecimalPlaces(123.456, 2)).toBe('123.46'); + expect(truncateDecimalPlaces(0.123456, 4)).toBe('0.1235'); + expect(truncateDecimalPlaces(100, 2)).toBe('100'); + }); + + it('handles edge cases', () => { + expect(truncateDecimalPlaces('', 2)).toBe(''); + expect(truncateDecimalPlaces('.123', 2)).toBe('0.12'); + expect(truncateDecimalPlaces(0, 2)).toBe('0'); + expect(truncateDecimalPlaces('.', 2)).toBe('.'); + }); + + it('preserves trailing zeros if present in input string', () => { + expect(truncateDecimalPlaces('123.450', 2)).toBe('123.45'); + expect(truncateDecimalPlaces('0.120', 3)).toBe('0.12'); + }); + + it('handles negative numbers', () => { + expect(truncateDecimalPlaces(-123.456, 2)).toBe('-123.46'); + expect(truncateDecimalPlaces('-0.123456', 4)).toBe('-0.1235'); + }); +}); diff --git a/src/internal/utils/truncateDecimalPlaces.ts b/src/internal/utils/truncateDecimalPlaces.ts new file mode 100644 index 0000000000..b0011921c1 --- /dev/null +++ b/src/internal/utils/truncateDecimalPlaces.ts @@ -0,0 +1,17 @@ +/** + * Limit the value to N decimal places + */ +export const truncateDecimalPlaces = ( + value: string | number, + decimalPlaces: number, +) => { + if (value === '' || value === '.') { + return value; + } + + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: decimalPlaces, + useGrouping: false, + }).format(Number(value)); +};