From 8cc4125a5a5d794fe636602a0d92c2dbffa75ab6 Mon Sep 17 00:00:00 2001 From: Alec Chen <93971719+0xAlec@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:00:32 -0500 Subject: [PATCH 1/3] ui --- biome.json | 2 +- .../bridge/components/AppchainBridge.test.tsx | 221 ++++++++++ .../bridge/components/AppchainBridge.tsx | 119 +++++ .../AppchainBridgeAddressInput.test.tsx | 108 +++++ .../components/AppchainBridgeAddressInput.tsx | 84 ++++ .../components/AppchainBridgeInput.test.tsx | 285 ++++++++++++ .../bridge/components/AppchainBridgeInput.tsx | 160 +++++++ .../components/AppchainBridgeNetwork.tsx | 62 +++ .../AppchainBridgeProvider.test.tsx | 407 ++++++++++++++++++ .../components/AppchainBridgeProvider.tsx | 310 +++++++++++++ .../AppchainBridgeTransactionButton.test.tsx | 146 +++++++ .../AppchainBridgeTransactionButton.tsx | 60 +++ .../AppchainBridgeWithdraw.test.tsx | 141 ++++++ .../components/AppchainBridgeWithdraw.tsx | 117 +++++ .../AppchainNetworkToggleButton.tsx | 32 ++ src/appchain/bridge/constants.ts | 8 + src/appchain/bridge/types.ts | 1 + .../bridge/utils/defaultPriceFetcher.test.ts | 77 ++++ .../bridge/utils/defaultPriceFetcher.ts | 19 + src/internal/svg/fullWidthSuccessSvg.tsx | 17 + 20 files changed, 2375 insertions(+), 1 deletion(-) create mode 100644 src/appchain/bridge/components/AppchainBridge.test.tsx create mode 100644 src/appchain/bridge/components/AppchainBridge.tsx create mode 100644 src/appchain/bridge/components/AppchainBridgeAddressInput.test.tsx create mode 100644 src/appchain/bridge/components/AppchainBridgeAddressInput.tsx create mode 100644 src/appchain/bridge/components/AppchainBridgeInput.test.tsx create mode 100644 src/appchain/bridge/components/AppchainBridgeInput.tsx create mode 100644 src/appchain/bridge/components/AppchainBridgeNetwork.tsx create mode 100644 src/appchain/bridge/components/AppchainBridgeProvider.test.tsx create mode 100644 src/appchain/bridge/components/AppchainBridgeProvider.tsx create mode 100644 src/appchain/bridge/components/AppchainBridgeTransactionButton.test.tsx create mode 100644 src/appchain/bridge/components/AppchainBridgeTransactionButton.tsx create mode 100644 src/appchain/bridge/components/AppchainBridgeWithdraw.test.tsx create mode 100644 src/appchain/bridge/components/AppchainBridgeWithdraw.tsx create mode 100644 src/appchain/bridge/components/AppchainNetworkToggleButton.tsx create mode 100644 src/appchain/bridge/utils/defaultPriceFetcher.test.ts create mode 100644 src/appchain/bridge/utils/defaultPriceFetcher.ts create mode 100644 src/internal/svg/fullWidthSuccessSvg.tsx diff --git a/biome.json b/biome.json index 90b583fc85..cfe13181ef 100644 --- a/biome.json +++ b/biome.json @@ -45,7 +45,7 @@ "useArrayLiterals": "error" }, "nursery": { - "useSortedClasses": "error" + "useSortedClasses": "off" }, "style": { "noImplicitBoolean": "error", diff --git a/src/appchain/bridge/components/AppchainBridge.test.tsx b/src/appchain/bridge/components/AppchainBridge.test.tsx new file mode 100644 index 0000000000..a59e1f64ec --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridge.test.tsx @@ -0,0 +1,221 @@ +import { useIsMounted } from '@/internal/hooks/useIsMounted'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { act } from 'react'; +import { parseEther } from 'viem'; +import { type Chain, baseSepolia } from 'viem/chains'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + http, + WagmiProvider, + createConfig, + mock, + useAccount, + useConfig, +} from 'wagmi'; +import { getBalance, readContract } from 'wagmi/actions'; +import type { Appchain } from '../types'; +import { AppchainBridge } from './AppchainBridge'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; +const queryClient = new QueryClient(); + +const mockConfig = createConfig({ + chains: [baseSepolia], + connectors: [ + mock({ + accounts: ['0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'], + }), + ], + transports: { + [baseSepolia.id]: http(), + }, +}); + +const mockChain = { + id: 1, + name: 'Ethereum', + nativeCurrency: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, +} as Chain; + +const mockAppchain = { + chain: { + id: 8453, + name: 'Base', + nativeCurrency: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, + }, +} as Appchain; + +const mockBridgeParams = { + token: { + address: '0x123', + remoteToken: '0x456', + decimals: 18, + chainId: 1, + image: '', + name: 'Mock Token', + symbol: 'MOCK', + }, + amount: '1', + recipient: '0x789', + amountUSD: '100', +} as const; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +vi.mock('wagmi', async (importOriginal) => { + return { + ...(await importOriginal()), + useAccount: vi.fn(), + useConfig: vi.fn(), + }; +}); + +vi.mock('wagmi/actions', () => ({ + getBalance: vi.fn(), + readContract: vi.fn(), +})); + +vi.mock('@/internal/hooks/useTheme', () => ({ + useTheme: vi.fn(), +})); + +vi.mock('@/internal/hooks/useIsMounted', () => ({ + useIsMounted: vi.fn(() => true), +})); + +vi.mock('./AppchainBridgeProvider', async () => ({ + useAppchainBridgeContext: vi.fn(), + AppchainBridgeProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); + +describe('AppchainBridge Component', () => { + beforeEach(() => { + (useAccount as Mock).mockReturnValue({ + address: '0x123', + }); + (useConfig as Mock).mockReturnValue({}); + (getBalance as ReturnType).mockResolvedValue({ + value: parseEther('1'), + decimals: 18, + }); + (readContract as ReturnType).mockResolvedValue( + parseEther('1'), + ); + (useAppchainBridgeContext as Mock).mockReturnValue({ + withdrawStatus: 'init', + isWithdrawModalOpen: false, + from: mockChain, + to: mockAppchain, + bridgeParams: mockBridgeParams, + }); + }); + + it('renders custom children when provided', async () => { + const customChildren =

Custom Children

; + await act(async () => { + render( + + {customChildren} + , + { wrapper }, + ); + }); + + expect(screen.getByText('Custom Children')).toBeInTheDocument(); + expect( + screen.queryByTestId('ockAppchainBridge_DefaultContent'), + ).not.toBeInTheDocument(); + }); + + it('renders default content when children are not provided', async () => { + await act(async () => { + render(, { + wrapper, + }); + }); + + expect( + screen.getByTestId('ockAppchainBridge_DefaultContent'), + ).toBeInTheDocument(); + }); + + it('renders with custom title', async () => { + await act(async () => { + render( + , + { wrapper }, + ); + }); + + expect(screen.getByText('Custom Bridge')).toBeInTheDocument(); + }); + + it('applies custom className', async () => { + await act(async () => { + render( + , + { wrapper }, + ); + }); + + expect(screen.getByTestId('ockAppchainBridge_Container')).toHaveClass( + 'custom-class', + ); + }); + + it('opens withdrawal modal when isWithdrawModalOpen is true', async () => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + isWithdrawModalOpen: true, + }); + await act(async () => { + render(, { + wrapper, + }); + }); + expect(screen.getByText('Confirming transaction')).toBeInTheDocument(); + }); + + it('renders address input when isAddressModalOpen is true', async () => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + isAddressModalOpen: true, + }); + await act(async () => { + render(, { + wrapper, + }); + }); + expect(screen.getByTestId('ockAppchainBridge_Address')).toBeInTheDocument(); + }); + + it('should not render when not mounted', () => { + (useIsMounted as Mock).mockReturnValueOnce(false); + const { container } = render( + , + { wrapper }, + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/appchain/bridge/components/AppchainBridge.tsx b/src/appchain/bridge/components/AppchainBridge.tsx new file mode 100644 index 0000000000..a1c718a164 --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridge.tsx @@ -0,0 +1,119 @@ +import { useIsMounted } from '@/internal/hooks/useIsMounted'; +import { useTheme } from '@/internal/hooks/useTheme'; +import { background, border, cn, color, text } from '@/styles/theme'; +import { DEFAULT_BRIDGEABLE_TOKENS } from '../constants'; +import type { AppchainBridgeReact, BridgeableToken } from '../types'; +import { AppchainBridgeAddressInput } from './AppchainBridgeAddressInput'; +import { AppchainBridgeInput } from './AppchainBridgeInput'; +import { AppchainBridgeNetwork } from './AppchainBridgeNetwork'; +import { AppchainBridgeProvider } from './AppchainBridgeProvider'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; +import { AppchainBridgeTransactionButton } from './AppchainBridgeTransactionButton'; +import { AppchainBridgeWithdraw } from './AppchainBridgeWithdraw'; +import { AppchainNetworkToggleButton } from './AppchainNetworkToggleButton'; + +const AppchainBridgeDefaultContent = ({ + title, + bridgeableTokens, +}: { + title: string; + bridgeableTokens?: BridgeableToken[]; +}) => { + const { isAddressModalOpen, isWithdrawModalOpen } = + useAppchainBridgeContext(); + + if (isWithdrawModalOpen) { + return ( +
+
+ +
+
+ ); + } + + if (isAddressModalOpen) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+
+

+ {title} +

+
+
+
+
+ + + +
+ + +
+
+
+
+ ); +}; + +export function AppchainBridge({ + chain, + appchain, + title = 'Bridge', + bridgeableTokens, + children = ( + + ), + className, +}: AppchainBridgeReact) { + const isMounted = useIsMounted(); + const componentTheme = useTheme(); + + if (!isMounted) { + return null; + } + + return ( + +
+ {children} +
+
+ ); +} diff --git a/src/appchain/bridge/components/AppchainBridgeAddressInput.test.tsx b/src/appchain/bridge/components/AppchainBridgeAddressInput.test.tsx new file mode 100644 index 0000000000..a874f0ebf7 --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridgeAddressInput.test.tsx @@ -0,0 +1,108 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { act } from 'react'; +import { + type Mock, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { AppchainBridgeAddressInput } from './AppchainBridgeAddressInput'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; + +vi.mock('./AppchainBridgeProvider', () => ({ + useAppchainBridgeContext: vi.fn(), +})); + +vi.mock('@/identity', () => ({ + Avatar: () =>
Avatar
, + Address: ({ address }: { address: string }) => ( +
{address}
+ ), +})); + +describe('AppchainBridgeAddressInput', () => { + const mockSetIsAddressModalOpen = vi.fn(); + const mockHandleAddressSelect = vi.fn(); + + beforeEach(() => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + setIsAddressModalOpen: mockSetIsAddressModalOpen, + handleAddressSelect: mockHandleAddressSelect, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders the component with initial state', () => { + render(); + + expect(screen.getByText('Send to')).toBeInTheDocument(); + expect(screen.getByText('To')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('handles back button click', () => { + render(); + + const backButton = screen.getByLabelText('Back button'); + fireEvent.click(backButton); + + expect(mockSetIsAddressModalOpen).toHaveBeenCalledWith(false); + }); + + it('shows error message for invalid address', async () => { + render(); + + const input = screen.getByRole('textbox'); + await act(async () => { + fireEvent.change(input, { target: { value: 'invalid-address' } }); + }); + + expect( + screen.getByText('Please enter a valid Ethereum address'), + ).toBeInTheDocument(); + }); + + it('shows address details for valid address', async () => { + render(); + + const validAddress = '0x1234567890123456789012345678901234567890'; + const input = screen.getByRole('textbox'); + + await act(async () => { + fireEvent.change(input, { target: { value: validAddress } }); + }); + + expect(screen.getByTestId('mock-avatar')).toBeInTheDocument(); + expect(screen.getAllByTestId('mock-address')).toHaveLength(2); + }); + + it('handles address selection for valid address', async () => { + render(); + const validAddress = '0x1234567890123456789012345678901234567890'; + const input = screen.getByRole('textbox'); + await act(async () => { + fireEvent.change(input, { target: { value: validAddress } }); + }); + const addressButton = screen.getByRole('button', { + name: new RegExp(validAddress), + }); + fireEvent.click(addressButton); + expect(mockHandleAddressSelect).toHaveBeenCalledWith(validAddress); + expect(mockSetIsAddressModalOpen).toHaveBeenCalledWith(false); + }); + + it('does not show address details or selection button for empty input', async () => { + render(); + expect(screen.queryByTestId('mock-avatar')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /0x/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/appchain/bridge/components/AppchainBridgeAddressInput.tsx b/src/appchain/bridge/components/AppchainBridgeAddressInput.tsx new file mode 100644 index 0000000000..b18f3fe9e5 --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridgeAddressInput.tsx @@ -0,0 +1,84 @@ +import { Avatar, Address as OCKAddress } from '@/identity'; +import { PressableIcon } from '@/internal/components/PressableIcon'; +import { TextInput } from '@/internal/components/TextInput'; +import { backArrowSvg } from '@/internal/svg/backArrowSvg'; +import { useState } from 'react'; +import type { Address } from 'viem'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; + +export const AppchainBridgeAddressInput = () => { + const { setIsAddressModalOpen, handleAddressSelect } = + useAppchainBridgeContext(); + const [address, setAddress] = useState
(null); + const [isValidAddress, setIsValidAddress] = useState(false); + + const validateAddress = (addr: string) => { + return /^0x[a-fA-F0-9]{40}$/.test(addr); + }; + + const backButton = ( + { + setIsAddressModalOpen(false); + }} + > +
{backArrowSvg}
+
+ ); + + return ( +
+
+ {backButton} +

+ Send to +

+
+
+
+ To + { + const addr = value as Address; + setAddress(addr); + setIsValidAddress(validateAddress(addr)); + }} + value={address || ''} + /> +
+ {address && !isValidAddress && ( +

+ Please enter a valid Ethereum address +

+ )} + {address && isValidAddress && ( + + )} +
+
+ ); +}; diff --git a/src/appchain/bridge/components/AppchainBridgeInput.test.tsx b/src/appchain/bridge/components/AppchainBridgeInput.test.tsx new file mode 100644 index 0000000000..c27208191d --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridgeInput.test.tsx @@ -0,0 +1,285 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { base, baseSepolia } from 'viem/chains'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { http, WagmiProvider, createConfig, mock, useAccount } from 'wagmi'; +import { ETH_BY_CHAIN } from '../constants'; +import type { BridgeableToken } from '../types'; +import { AppchainBridgeInput } from './AppchainBridgeInput'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; + +const queryClient = new QueryClient(); + +const mockConfig = createConfig({ + chains: [baseSepolia], + connectors: [ + mock({ + accounts: ['0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'], + }), + ], + transports: { + [baseSepolia.id]: http(), + }, +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const mockToken = { + name: 'ETH', + address: '', + symbol: 'ETH', + decimals: 18, + image: + 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', + chainId: base.id, + remoteToken: ETH_BY_CHAIN[base.id].address, +} as BridgeableToken; + +const mockBridgeContext = { + balance: '1.5', + bridgeParams: { + amount: '', + amountUSD: '0.00', + recipient: '0x123', + token: mockToken, + }, + to: { + id: 8453, + icon: '🔵', + }, + isPriceLoading: false, + handleAmountChange: vi.fn(), + setIsAddressModalOpen: vi.fn(), +}; + +vi.mock('wagmi', async (importOriginal) => { + return { + ...(await importOriginal()), + useAccount: vi.fn(), + }; +}); + +vi.mock('./AppchainBridgeProvider', () => ({ + useAppchainBridgeContext: vi.fn(), +})); + +vi.mock('@/internal/hooks/usePreferredColorScheme', () => ({ + usePreferredColorScheme: vi.fn().mockReturnValue('light'), +})); + +describe('AppchainBridgeInput', () => { + beforeEach(() => { + vi.resetAllMocks(); + + (useAccount as Mock).mockReturnValue({ + address: '0x123', + status: 'connected', + }); + + (useAppchainBridgeContext as Mock).mockReturnValue(mockBridgeContext); + }); + + it('renders correctly with default props', () => { + render(, { wrapper }); + + expect( + screen.getByTestId('ockBridgeAmountInput_Container'), + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText('0.00')).toBeInTheDocument(); + expect(screen.getByText('~$0.00')).toBeInTheDocument(); + expect(screen.getByText('Balance: 1.5')).toBeInTheDocument(); + }); + + it('handles amount changes', async () => { + const mockHandleAmountChange = vi.fn(); + (useAppchainBridgeContext as Mock).mockReturnValue({ + ...mockBridgeContext, + handleAmountChange: mockHandleAmountChange, + resetDepositStatus: vi.fn(), + }); + + render(, { wrapper }); + const input = screen.getByPlaceholderText('0.00'); + await userEvent.type(input, '2'); + await waitFor(() => { + expect(mockHandleAmountChange).toHaveBeenCalledWith({ + amount: '2', + token: mockToken, + }); + }); + }); + + it('shows loading state when price is loading', () => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + ...mockBridgeContext, + isPriceLoading: true, + }); + + render(, { wrapper }); + expect( + screen + .getByTestId('ockBridgeAmountInput_Container') + .querySelector('.animate-pulse'), + ).toBeInTheDocument(); + }); + + it('shows insufficient funds message when balance is too low', async () => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + ...mockBridgeContext, + balance: '0.5', + bridgeParams: { + ...mockBridgeContext.bridgeParams, + amount: '0.6', + }, + }); + + render(, { wrapper }); + const input = screen.getByPlaceholderText('0.00'); + await userEvent.type(input, '0.6'); + await waitFor(() => { + expect(input).toHaveValue('0.6'); + expect(screen.getByText('Insufficient funds')).toBeInTheDocument(); + }); + }); + + it('handles wallet disconnected state', () => { + (useAccount as Mock).mockReturnValue({ + address: undefined, + status: 'disconnected', + }); + + render(, { wrapper }); + expect(screen.queryByText('Balance:')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('ockBridgeAmountInput_MaxButton'), + ).not.toBeInTheDocument(); + }); + + it('handles custom bridgeable tokens', () => { + const customToken = { + ...mockToken, + symbol: 'CUSTOM', + }; + + render(, { + wrapper, + }); + expect(screen.getByText('CUSTOM')).toBeInTheDocument(); + }); + + it('opens address modal when clicking recipient address', async () => { + const mockSetIsAddressModalOpen = vi.fn(); + (useAppchainBridgeContext as Mock).mockReturnValue({ + ...mockBridgeContext, + setIsAddressModalOpen: mockSetIsAddressModalOpen, + }); + + render(, { wrapper }); + const addressButton = screen.getByText('0x123...x123'); + + await userEvent.click(addressButton); + expect(mockSetIsAddressModalOpen).toHaveBeenCalledWith(true); + }); + + it('displays NaN amount correctly', () => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + ...mockBridgeContext, + bridgeParams: { + ...mockBridgeContext.bridgeParams, + amountUSD: 'NaN', + }, + }); + render(, { wrapper }); + expect(screen.queryByText(/\$|~\$/)).not.toBeInTheDocument(); + }); + + it('handles max button click correctly', async () => { + const mockHandleAmountChange = vi.fn(); + (useAppchainBridgeContext as Mock).mockReturnValue({ + ...mockBridgeContext, + handleAmountChange: mockHandleAmountChange, + balance: '1.5', + }); + render(, { wrapper }); + const maxButton = screen.getByTestId('ockBridgeAmountInput_MaxButton'); + await userEvent.click(maxButton); + expect(mockHandleAmountChange).toHaveBeenCalledWith({ + amount: '1.5', + token: mockToken, + }); + }); + + it('formats balance with correct decimal places', () => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + ...mockBridgeContext, + balance: '1.23456789', + }); + render(, { wrapper }); + expect(screen.getByText('Balance: 1.23457')).toBeInTheDocument(); + }); + + it('formats whole number balance without decimal places', () => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + ...mockBridgeContext, + balance: '100', + }); + render(, { wrapper }); + expect(screen.getByText('Balance: 100')).toBeInTheDocument(); + }); + + it('shows default address when recipient is not set', () => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + ...mockBridgeContext, + bridgeParams: { + ...mockBridgeContext.bridgeParams, + recipient: '', + }, + }); + render(, { wrapper }); + expect(screen.getByText('0x0000...0000')).toBeInTheDocument(); + }); + + it('handles token selection correctly', async () => { + const mockHandleAmountChange = vi.fn(); + const mockResetDepositStatus = vi.fn(); + const customToken = { + ...mockToken, + symbol: 'CUSTOM', + address: '0x456', + } as BridgeableToken; + (useAppchainBridgeContext as Mock).mockReturnValue({ + ...mockBridgeContext, + handleAmountChange: mockHandleAmountChange, + resetDepositStatus: mockResetDepositStatus, + bridgeParams: { + ...mockBridgeContext.bridgeParams, + amount: '1.0', + }, + }); + render( + , + { wrapper }, + ); + + await waitFor(async () => { + // Open token dropdown + const tokenButton = screen.getByText('ETH'); + await userEvent.click(tokenButton); + // Select new token + const customTokenOption = screen.getByText('CUSTOM'); + await userEvent.click(customTokenOption); + }); + + // Verify handleAmountChange was called with correct params + expect(mockHandleAmountChange).toHaveBeenCalledWith({ + amount: '1.0', + token: customToken, + }); + expect(mockResetDepositStatus).toHaveBeenCalled(); + }); +}); diff --git a/src/appchain/bridge/components/AppchainBridgeInput.tsx b/src/appchain/bridge/components/AppchainBridgeInput.tsx new file mode 100644 index 0000000000..59ca88614a --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridgeInput.tsx @@ -0,0 +1,160 @@ +'use client'; +import { Address } from '@/identity'; +import { useMemo, useState } from 'react'; +import { useAccount } from 'wagmi'; +import { TextInput } from '../../../internal/components/TextInput'; +import { isValidAmount } from '../../../internal/utils/isValidAmount'; +import { background, border, cn, color, text } from '../../../styles/theme'; +import { TokenSelectDropdown } from '../../../token'; +import { DEFAULT_BRIDGEABLE_TOKENS } from '../constants'; +import type { BridgeableToken } from '../types'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; + +interface AppchainBridgeInputProps { + className?: string; + delayMs?: number; + bridgeableTokens?: BridgeableToken[]; +} + +export function AppchainBridgeInput({ + className, + delayMs = 50, + bridgeableTokens = DEFAULT_BRIDGEABLE_TOKENS, +}: AppchainBridgeInputProps) { + const { + balance, + bridgeParams, + to, + isPriceLoading, + handleAmountChange, + setIsAddressModalOpen, + resetDepositStatus, + } = useAppchainBridgeContext(); + const [currentToken, setCurrentToken] = useState(bridgeableTokens[0]); + const { address } = useAccount(); + const insufficientBalance = useMemo(() => { + return balance && Number(balance) < Number(bridgeParams.amount); + }, [balance, bridgeParams.amount]); + + const label = insufficientBalance ? ( + 'Insufficient funds' + ) : isPriceLoading ? ( +
+ ) : bridgeParams.amountUSD === 'NaN' ? ( + '' + ) : ( + `~$${bridgeParams.amountUSD}` + ); + + return ( +
+
+ + Send to{' '} + {' '} + on {to.icon} + +
+
+ { + handleAmountChange({ + amount: value, + token: currentToken, + }); + }} + value={bridgeParams.amount} + /> + { + handleAmountChange({ + amount: bridgeParams.amount, + token: token, + }); + resetDepositStatus(); + setCurrentToken(token); + }} + /> +
+
+
+ + {label} + +
+ {address && ( +
+ {`Balance: ${Number(balance).toLocaleString(undefined, { + maximumFractionDigits: 5, + minimumFractionDigits: 0, + })}`} + +
+ )} +
+
+ ); +} diff --git a/src/appchain/bridge/components/AppchainBridgeNetwork.tsx b/src/appchain/bridge/components/AppchainBridgeNetwork.tsx new file mode 100644 index 0000000000..530a88caf5 --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridgeNetwork.tsx @@ -0,0 +1,62 @@ +import { background, border, cn, text } from '@/styles/theme'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; + +interface AppchainBridgeNetworkReact { + type: 'from' | 'to'; + label: string; +} + +export const AppchainBridgeNetwork = ({ + type, + label, +}: AppchainBridgeNetworkReact) => { + const { from, to } = useAppchainBridgeContext(); + + const displayNetwork = type === 'from' ? from.name : to.name; + const displayIcon = type === 'from' ? from.icon : to.icon; + + return ( +
+
+
+
+ {label} +
+
+
+ {displayIcon} +
+ {displayNetwork} +
+
+
+
+ ); +}; diff --git a/src/appchain/bridge/components/AppchainBridgeProvider.test.tsx b/src/appchain/bridge/components/AppchainBridgeProvider.test.tsx new file mode 100644 index 0000000000..d0b838cffd --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridgeProvider.test.tsx @@ -0,0 +1,407 @@ +import type { Token } from '@/token'; +import { renderHook, waitFor } from '@testing-library/react'; +import { act } from 'react'; +import { type Chain, parseEther } from 'viem'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAccount, useConfig } from 'wagmi'; +import { getBalance, readContract } from 'wagmi/actions'; +import { useChainConfig } from '../hooks/useAppchainConfig'; +import { useDeposit } from '../hooks/useDeposit'; +import { useWithdraw } from '../hooks/useWithdraw'; +import type { Appchain, BridgeableToken } from '../types'; +import { + AppchainBridgeProvider, + useAppchainBridgeContext, +} from './AppchainBridgeProvider'; + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), + useConfig: vi.fn(), +})); + +vi.mock('wagmi/actions', () => ({ + getBalance: vi.fn(), + readContract: vi.fn(), +})); + +vi.mock('../hooks/useAppchainConfig', () => ({ + useChainConfig: vi.fn(), +})); + +vi.mock('../hooks/useDeposit', () => ({ + useDeposit: vi.fn(), +})); + +vi.mock('../hooks/useWithdraw', () => ({ + useWithdraw: vi.fn(), +})); + +vi.mock('../utils/getETHPrice', () => ({ + getETHPrice: vi.fn().mockResolvedValue('2000'), +})); + +const mockChain = { + id: 1, + name: 'Ethereum', + nativeCurrency: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, +} as Chain; + +const mockAppchain = { + chain: { + id: 8453, + name: 'Base', + nativeCurrency: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, + }, +} as Appchain; + +const mockConfig = { + chainId: 8453, + contracts: { + l2OutputOracle: '0x123', + systemConfig: '0x456', + optimismPortal: '0x789', + }, +}; + +const mockToken = { + address: '', + symbol: 'ETH', + decimals: 18, + chainId: 1, + image: '', + name: 'ETH', +} as Token; + +const mockBridgeableTokens = [mockToken]; + +describe('AppchainBridgeProvider', () => { + let result: { + current: ReturnType; + }; + + const renderBridgeProvider = async (props = {}) => { + let hookResult!: { + current: ReturnType; + }; + await act(async () => { + const rendered = renderHook(() => useAppchainBridgeContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + hookResult = rendered.result; + }); + return hookResult; + }; + + beforeEach(async () => { + vi.resetAllMocks(); + vi.stubGlobal('open', vi.fn()); + + (useAccount as ReturnType).mockReturnValue({ + address: '0x123', + }); + (useConfig as ReturnType).mockReturnValue({}); + (useChainConfig as ReturnType).mockReturnValue({ + config: mockConfig, + error: null, + }); + (useDeposit as ReturnType).mockReturnValue({ + deposit: vi.fn(), + depositStatus: 'idle', + resetDepositStatus: vi.fn(), + }); + (useWithdraw as ReturnType).mockReturnValue({ + withdraw: vi.fn(), + withdrawStatus: 'idle', + resetWithdrawStatus: vi.fn(), + waitForWithdrawal: vi.fn(), + proveAndFinalizeWithdrawal: vi.fn(), + }); + (getBalance as ReturnType).mockResolvedValue({ + value: parseEther('1'), + decimals: 18, + }); + (readContract as ReturnType).mockResolvedValue( + parseEther('1'), + ); + + result = await renderBridgeProvider(); + }); + + it('should throw error when used outside provider', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + expect(() => { + renderHook(() => useAppchainBridgeContext()); + }).toThrow('useAppchainBridge must be used within a BridgeProvider'); + consoleErrorSpy.mockRestore(); + }); + + it('should initialize with correct default values', async () => { + await waitFor(() => { + expect(result.current.from.id).toBe(mockChain.id); + expect(result.current.to.id).toBe(mockAppchain.chain.id); + expect(result.current.bridgeParams.amount).toBe(''); + expect(result.current.bridgeParams.amountUSD).toBe('0.00'); + }); + }); + + it('should update recipient when wallet connects', async () => { + await waitFor(() => { + expect(result.current.bridgeParams.recipient).toBe('0x123'); + }); + }); + + it('should handle network toggle correctly', async () => { + const initialFrom = result.current.from; + const initialTo = result.current.to; + + await waitFor(async () => { + result.current.handleToggle(); + }); + + expect(result.current.from).toEqual(initialTo); + expect(result.current.to).toEqual(initialFrom); + }); + + it('should handle amount changes with price updates', async () => { + result = await renderBridgeProvider({ + handleFetchPrice: () => Promise.resolve('2000.00'), + }); + + await waitFor(async () => { + result.current.handleAmountChange({ + amount: '1', + token: mockToken, + }); + }); + + await waitFor(() => { + expect(result.current.bridgeParams.amount).toBe('1'); + expect(result.current.bridgeParams.amountUSD).toBe('2000.00'); + }); + }); + + it('should handle withdraw modal state changes', async () => { + await waitFor(async () => { + result.current.setIsWithdrawModalOpen(true); + }); + expect(result.current.isWithdrawModalOpen).toBe(true); + + await waitFor(async () => { + result.current.setIsWithdrawModalOpen(false); + }); + expect(result.current.isWithdrawModalOpen).toBe(false); + }); + + it('should handle address modal state changes', async () => { + await waitFor(async () => { + result.current.setIsAddressModalOpen(true); + }); + expect(result.current.isAddressModalOpen).toBe(true); + + await waitFor(async () => { + result.current.handleAddressSelect('0x456' as `0x${string}`); + }); + expect(result.current.bridgeParams.recipient).toBe('0x456'); + }); + + it('should call deposit function when handleDeposit is called', async () => { + const mockDeposit = vi.fn(); + (useDeposit as Mock).mockReturnValue({ + deposit: mockDeposit, + depositStatus: 'idle', + transactionHash: '', + resetDepositStatus: vi.fn(), + }); + const result = await renderBridgeProvider(); + await waitFor(async () => { + result.current.handleDeposit(); + }); + expect(mockDeposit).toHaveBeenCalled(); + }); + + it('should open correct block explorer based on chain ID when deposit is successful', async () => { + const mockDeposit = vi.fn(); + (useDeposit as Mock).mockReturnValue({ + deposit: mockDeposit, + depositStatus: 'depositSuccess', + transactionHash: '0xtx', + resetDepositStatus: vi.fn(), + }); + // Test with Base mainnet + const baseChain = { + ...mockChain, + id: 8453, // Base mainnet chain ID + }; + const baseResult = await renderBridgeProvider({ + chain: baseChain, + appchain: mockAppchain, + }); + await waitFor(async () => { + baseResult.current.handleDeposit(); + }); + expect(window.open).toHaveBeenCalledWith( + 'https://basescan.org/tx/0xtx', + '_blank', + ); + // Reset mocks + vi.clearAllMocks(); + // Test with Base Sepolia + const sepoliaChain = { + ...mockChain, + id: 84532, // Base Sepolia chain ID + }; + const sepoliaResult = await renderBridgeProvider({ + chain: sepoliaChain, + appchain: mockAppchain, + }); + await waitFor(async () => { + sepoliaResult.current.handleDeposit(); + }); + expect(window.open).toHaveBeenCalledWith( + 'https://sepolia.basescan.org/tx/0xtx', + '_blank', + ); + }); + + it('should validate required props', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); // Suppress error logging + expect(() => { + renderHook(() => useAppchainBridgeContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + }).toThrow( + 'Bridgeable tokens must be provided as a parameter to AppchainBridge.', + ); + consoleErrorSpy.mockRestore(); // Restore console.error after test + }); + + it('should throw error when chain config has error', () => { + (useChainConfig as Mock).mockReturnValue({ + config: null, + error: new Error('Chain config error'), + }); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); // Suppress error logging + expect(() => { + renderHook(() => useAppchainBridgeContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + }).toThrow( + 'Error loading chain configuration. Ensure you have the correct chain ID.', + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + new Error('Chain config error'), + ); + consoleErrorSpy.mockRestore(); // Restore console.error after test + }); + + it('should render empty fragment when config is null', async () => { + (useChainConfig as Mock).mockReturnValue({ + config: null, + error: null, + }); + const result = await renderBridgeProvider(); + expect(result.current).toBeNull(); + }); + + it('should not fetch balance when address is not set', async () => { + (useAccount as Mock).mockReturnValue({ + address: '', + }); + const result = await renderBridgeProvider(); + expect(result.current.balance).toBe(''); + }); + + it('should fetch ERC-20 balance correctly', async () => { + // Mock an ERC-20 token + const mockERC20Token = { + ...mockToken, + address: '0xTokenAddress', + decimals: 6, + } as BridgeableToken; + // Mock readContract to return a specific balance + (readContract as Mock).mockResolvedValue(1000000n); + const result = await renderBridgeProvider({ + bridgeableTokens: [mockERC20Token], + }); + // Wait for the balance to be fetched + await waitFor(() => { + expect(readContract).toHaveBeenCalledWith(expect.anything(), { + abi: expect.arrayContaining([ + expect.objectContaining({ + name: 'balanceOf', + }), + ]), + address: '0xTokenAddress', + args: ['0x123'], + chainId: mockChain.id, + functionName: 'balanceOf', + }); + expect(result.current.balance).toBe('1'); + }); + }); + + it('should call withdraw function when handleWithdraw is called', async () => { + const mockWithdraw = vi.fn(); + (useWithdraw as Mock).mockReturnValue({ + withdraw: mockWithdraw, + withdrawStatus: 'idle', + resetWithdrawStatus: vi.fn(), + }); + const result = await renderBridgeProvider(); + await waitFor(async () => { + result.current.handleWithdraw(); + }); + expect(mockWithdraw).toHaveBeenCalled(); + }); + + it('should open withdraw modal when withdraw is successful', async () => { + (useWithdraw as Mock).mockReturnValue({ + withdrawStatus: 'withdrawSuccess', + resetWithdrawStatus: vi.fn(), + }); + const result = await renderBridgeProvider(); + await waitFor(async () => { + expect(result.current.isWithdrawModalOpen).toBe(true); + }); + }); +}); diff --git a/src/appchain/bridge/components/AppchainBridgeProvider.tsx b/src/appchain/bridge/components/AppchainBridgeProvider.tsx new file mode 100644 index 0000000000..645c259e81 --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridgeProvider.tsx @@ -0,0 +1,310 @@ +import { useValue } from '@/internal/hooks/useValue'; +import { baseSvg } from '@/internal/svg/baseSvg'; +import { coinbaseLogoSvg } from '@/internal/svg/coinbaseLogoSvg'; +import { toReadableAmount } from '@/swap/utils/toReadableAmount'; +import type { Token } from '@/token'; +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { type Address, type Chain, erc20Abi } from 'viem'; +import { base } from 'viem/chains'; +import { useAccount, useConfig } from 'wagmi'; +import { getBalance, readContract } from 'wagmi/actions'; +import { useChainConfig } from '../hooks/useAppchainConfig'; +import { useDeposit } from '../hooks/useDeposit'; +import { useWithdraw } from '../hooks/useWithdraw'; +import type { Appchain, ChainWithIcon } from '../types'; +import type { BridgeParams } from '../types'; +import type { AppchainBridgeContextType } from '../types'; +import { defaultPriceFetcher } from '../utils/defaultPriceFetcher'; + +const AppchainBridgeContext = createContext< + AppchainBridgeContextType | undefined +>(undefined); + +interface AppchainBridgeProviderProps { + children: ReactNode; + chain: Chain; + appchain: Appchain; + bridgeableTokens: Token[]; + handleFetchPrice?: (amount: string, token: Token) => Promise; +} + +export const AppchainBridgeProvider = ({ + children, + chain, + appchain, + bridgeableTokens, + handleFetchPrice = defaultPriceFetcher, +}: AppchainBridgeProviderProps) => { + // Source network + const [from, setFrom] = useState({ + ...chain, + icon: baseSvg, + }); + // Destination network + const [to, setTo] = useState({ + ...appchain.chain, + icon: appchain.icon || coinbaseLogoSvg, + }); + // op-enclave configuration https://github.com/base/op-enclave/blob/main/contracts/src/DeployChain.sol + const { config, error } = useChainConfig({ + l2ChainId: chain.id, + appchainChainId: appchain.chain.id, + }); + + if (error) { + console.error(error); + throw new Error( + 'Error loading chain configuration. Ensure you have the correct chain ID.', + ); + } + if (bridgeableTokens.length === 0) { + throw new Error( + 'Bridgeable tokens must be provided as a parameter to AppchainBridge.', + ); + } + if (!config) { + return <>; + } + + // Wagmi hooks + const { address } = useAccount(); + const wagmiConfig = useConfig(); + + // Bridge params + const [bridgeParams, setBridgeParams] = useState({ + amount: '', + amountUSD: '0.00', + token: bridgeableTokens[0], + recipient: address, + }); + + // Bridge state + const [isPriceLoading, setIsPriceLoading] = useState(false); + const [isAddressModalOpen, setIsAddressModalOpen] = useState(false); + const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false); + const direction = from.id === chain.id ? 'deposit' : 'withdraw'; + const [balance, setBalance] = useState(''); + + // Deposit + const { + deposit, + depositStatus, + transactionHash: depositTransactionHash, + resetDepositStatus, + } = useDeposit(); + const { + withdraw, + withdrawStatus, + waitForWithdrawal, + proveAndFinalizeWithdrawal, + resetWithdrawStatus, + } = useWithdraw({ + config, + chain, + bridgeParams, + }); + + // Update recipient when wallet connects + // Defaults to current wallet address + useEffect(() => { + setBridgeParams((prev) => ({ + ...prev, + recipient: address, + })); + }, [address]); + + // Retrieves the ETH or ERC20 balance of the user + // Based on the currently selected token + const fetchBalance = useCallback(async () => { + if (!address) { + return; + } + + const tokenAddress = + direction === 'deposit' + ? bridgeParams.token.address + : bridgeParams.token.remoteToken; + + let _balance: string; + if (tokenAddress) { + const erc20Balance = await readContract(wagmiConfig, { + abi: erc20Abi, + functionName: 'balanceOf', + args: [address], + address: tokenAddress as Address, + chainId: from.id, + }); + _balance = toReadableAmount( + erc20Balance.toString(), + bridgeParams.token.decimals, + ); + } else { + const ethBalance = await getBalance(wagmiConfig, { + address, + chainId: from.id, + }); + _balance = toReadableAmount( + ethBalance.value.toString(), + ethBalance.decimals, + ); + } + + setBalance(_balance); + }, [address, direction, bridgeParams.token, from.id, wagmiConfig]); + + // Fetch balance when bridge params change + useEffect(() => { + fetchBalance(); + }, [fetchBalance]); + + // Fetch balance when withdraw is successful + useEffect(() => { + if ( + withdrawStatus === 'claimSuccess' || + depositStatus === 'depositSuccess' + ) { + fetchBalance(); + } + }, [withdrawStatus, depositStatus, fetchBalance]); + + const handleToggle = useCallback(() => { + const tmp = from; + setFrom(to); + setTo(tmp); + // Reset statuses when direction changes + resetDepositStatus(); + resetWithdrawStatus(); + }, [from, to, resetDepositStatus, resetWithdrawStatus]); + + const handleAmountChange = useCallback( + async ({ + amount, + token, + remoteToken, + }: { + amount: string; + token: Token; + remoteToken?: Token; + }) => { + setIsPriceLoading(true); + setBridgeParams((prev) => ({ + ...prev, + amount, + token, + remoteToken, + })); + + const amountUSD = await handleFetchPrice(amount, token); + + setBridgeParams((prev) => ({ + ...prev, + amountUSD, + })); + setIsPriceLoading(false); + }, + [handleFetchPrice], + ); + + const handleAddressSelect = useCallback((address: Address) => { + setBridgeParams((prev) => ({ + ...prev, + recipient: address, + })); + }, []); + + const handleDeposit = useCallback(async () => { + const blockExplorerUrl = + from.id === base.id + ? 'https://basescan.org/tx/' + : 'https://sepolia.basescan.org/tx/'; + + if (depositStatus === 'depositSuccess') { + window.open(`${blockExplorerUrl}${depositTransactionHash}`, '_blank'); + return; + } + + await deposit({ + config, + from, + bridgeParams, + }); + }, [ + deposit, + depositStatus, + config, + from, + bridgeParams, + depositTransactionHash, + ]); + + const handleWithdraw = useCallback(async () => { + await withdraw(); + }, [withdraw]); + + // Open withdraw modal when withdraw is successful + useEffect(() => { + if (withdrawStatus === 'withdrawSuccess') { + setIsWithdrawModalOpen(true); + } + }, [withdrawStatus]); + + // Reset withdraw status when withdraw modal is closed + useEffect(() => { + if (!isWithdrawModalOpen) { + resetWithdrawStatus(); + } + }, [isWithdrawModalOpen, resetWithdrawStatus]); + + const value = useValue({ + // Internal + config, + from, + to, + bridgeParams, + isPriceLoading, + + // Bridge UI + balance, + handleToggle, + handleAmountChange, + + // Address modal + isAddressModalOpen, + setIsAddressModalOpen, + handleAddressSelect, + + // Deposits and Withdrawals + handleDeposit, + depositStatus, + direction, + handleWithdraw, + withdrawStatus, + waitForWithdrawal, + proveAndFinalizeWithdrawal, + isWithdrawModalOpen, + setIsWithdrawModalOpen, + resetDepositStatus, + resetWithdrawStatus, + }); + + return ( + + {children} + + ); +}; + +export const useAppchainBridgeContext = () => { + const context = useContext(AppchainBridgeContext); + if (context === undefined) { + throw new Error('useAppchainBridge must be used within a BridgeProvider'); + } + return context; +}; diff --git a/src/appchain/bridge/components/AppchainBridgeTransactionButton.test.tsx b/src/appchain/bridge/components/AppchainBridgeTransactionButton.test.tsx new file mode 100644 index 0000000000..ed123d2749 --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridgeTransactionButton.test.tsx @@ -0,0 +1,146 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { baseSepolia } from 'viem/chains'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + http, + WagmiProvider, + createConfig, + mock, + useAccount, + useConnect, +} from 'wagmi'; +import { useDepositButton } from '../hooks/useDepositButton'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; +import { AppchainBridgeTransactionButton } from './AppchainBridgeTransactionButton'; + +const queryClient = new QueryClient(); + +const mockConfig = createConfig({ + chains: [baseSepolia], + connectors: [ + mock({ + accounts: ['0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'], + }), + ], + transports: { + [baseSepolia.id]: http(), + }, +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +vi.mock('wagmi', async (importOriginal) => { + return { + ...(await importOriginal()), + useAccount: vi.fn(), + useConnect: vi.fn(), + }; +}); + +vi.mock('./AppchainBridgeProvider', () => ({ + useAppchainBridgeContext: vi.fn(), +})); + +vi.mock('../hooks/useDepositButton', () => ({ + useDepositButton: vi.fn(), +})); + +describe('AppchainBridgeTransactionButton', () => { + const mockHandleDeposit = vi.fn(); + const mockHandleWithdraw = vi.fn(); + const mockConnect = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + + (useAccount as Mock).mockReturnValue({ + isConnected: true, + }); + + (useConnect as Mock).mockReturnValue({ + connect: mockConnect, + }); + + (useAppchainBridgeContext as Mock).mockReturnValue({ + handleDeposit: mockHandleDeposit, + handleWithdraw: mockHandleWithdraw, + depositStatus: 'idle', + withdrawStatus: 'idle', + direction: 'deposit', + bridgeParams: { + amount: '1', + token: { + symbol: 'ETH', + }, + }, + }); + + (useDepositButton as Mock).mockReturnValue({ + isRejected: false, + buttonContent: 'Confirm', + isDisabled: false, + }); + }); + + it('renders with default state', () => { + render(, { wrapper }); + expect(screen.getByText('Confirm')).toBeInTheDocument(); + }); + + it('handles deposit direction correctly', async () => { + render(, { wrapper }); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(mockHandleDeposit).toHaveBeenCalled(); + }); + + it('handles withdraw direction correctly', () => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + ...useAppchainBridgeContext(), + direction: 'withdraw', + }); + render(, { wrapper }); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(mockHandleWithdraw).toHaveBeenCalled(); + }); + + it('disables button when isDisabled is true', () => { + (useDepositButton as Mock).mockReturnValue({ + isRejected: false, + buttonContent: 'Confirm', + isDisabled: true, + }); + + render(, { wrapper }); + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + }); + + it('shows error message when transaction is rejected', () => { + (useDepositButton as Mock).mockReturnValue({ + isRejected: true, + buttonContent: 'Confirm', + isDisabled: false, + }); + + render(, { wrapper }); + expect(screen.getByText('Transaction denied')).toBeInTheDocument(); + }); + + it('shows loading state during transaction', () => { + (useDepositButton as Mock).mockReturnValue({ + isRejected: false, + buttonContent: 'Processing...', + isDisabled: true, + }); + + render(, { wrapper }); + expect(screen.getByText('Processing...')).toBeInTheDocument(); + }); +}); diff --git a/src/appchain/bridge/components/AppchainBridgeTransactionButton.tsx b/src/appchain/bridge/components/AppchainBridgeTransactionButton.tsx new file mode 100644 index 0000000000..2b2e6c2089 --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridgeTransactionButton.tsx @@ -0,0 +1,60 @@ +import { cn } from '@/styles/theme'; +import { border, color, pressable, text } from '@/styles/theme'; +import { ConnectWallet } from '@/wallet'; +import { useAccount } from 'wagmi'; +import { useDepositButton } from '../hooks/useDepositButton'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; + +export const AppchainBridgeTransactionButton = () => { + const { + handleDeposit, + depositStatus, + direction, + handleWithdraw, + withdrawStatus, + } = useAppchainBridgeContext(); + const { bridgeParams } = useAppchainBridgeContext(); + const { isConnected } = useAccount(); + + const { isRejected, buttonContent, isDisabled } = useDepositButton({ + depositStatus, + withdrawStatus, + bridgeParams, + }); + + const buttonHandler = + direction === 'deposit' ? handleDeposit : handleWithdraw; + + if (!isConnected) { + return ; + } + + return ( +
+ + {isRejected && ( +
+ Transaction denied +
+ )} +
+ ); +}; diff --git a/src/appchain/bridge/components/AppchainBridgeWithdraw.test.tsx b/src/appchain/bridge/components/AppchainBridgeWithdraw.test.tsx new file mode 100644 index 0000000000..125ff19d03 --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridgeWithdraw.test.tsx @@ -0,0 +1,141 @@ +import { Spinner } from '@/internal/components/Spinner'; +import { pressable } from '@/styles/theme'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useWithdrawButton } from '../hooks/useWithdrawButton'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; +import { AppchainBridgeWithdraw } from './AppchainBridgeWithdraw'; + +vi.mock('./AppchainBridgeProvider', () => ({ + useAppchainBridgeContext: vi.fn(), +})); + +vi.mock('../hooks/useWithdrawButton', () => ({ + useWithdrawButton: vi.fn(), +})); + +describe('AppchainBridgeWithdraw', () => { + const mockSetIsWithdrawModalOpen = vi.fn(); + const mockWaitForWithdrawal = vi.fn(); + const mockProveAndFinalizeWithdrawal = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + + (useAppchainBridgeContext as Mock).mockReturnValue({ + withdrawStatus: 'init', + waitForWithdrawal: mockWaitForWithdrawal, + proveAndFinalizeWithdrawal: mockProveAndFinalizeWithdrawal, + setIsWithdrawModalOpen: mockSetIsWithdrawModalOpen, + }); + + (useWithdrawButton as Mock).mockReturnValue({ + isSuccess: false, + buttonDisabled: false, + buttonContent: 'Claim', + shouldShowClaim: false, + label: 'Withdraw', + }); + }); + + it('renders the loading state correctly', () => { + (useWithdrawButton as Mock).mockReturnValue({ + isSuccess: false, + buttonDisabled: false, + buttonContent: 'Claim', + shouldShowClaim: false, + label: 'Withdraw', + }); + + render(); + + expect(screen.getByText('Withdraw')).toBeInTheDocument(); + expect( + screen.getByText((content) => + content.includes('Waiting for claim to be ready...'), + ), + ).toBeInTheDocument(); + expect( + screen.getByText((content) => + content.includes('Please do not close this window.'), + ), + ).toBeInTheDocument(); + }); + + it('renders the claim state correctly', () => { + (useWithdrawButton as Mock).mockReturnValue({ + isSuccess: false, + buttonDisabled: false, + buttonContent: 'Claim Funds', + shouldShowClaim: true, + label: 'Claim', + }); + + render(); + + const claimButton = screen.getByText('Claim Funds'); + expect(claimButton).toBeInTheDocument(); + + fireEvent.click(claimButton); + expect(mockProveAndFinalizeWithdrawal).toHaveBeenCalledTimes(1); + }); + + it('renders the success state correctly', () => { + (useWithdrawButton as Mock).mockReturnValue({ + isSuccess: true, + buttonDisabled: false, + buttonContent: '', + shouldShowClaim: false, + label: 'Success', + }); + + render(); + + const backButton = screen.getByText('Back to Bridge'); + expect(backButton).toBeInTheDocument(); + + fireEvent.click(backButton); + expect(mockSetIsWithdrawModalOpen).toHaveBeenCalledWith(false); + }); + + it('shows error message when claim is rejected', () => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + withdrawStatus: 'claimRejected', + waitForWithdrawal: mockWaitForWithdrawal, + proveAndFinalizeWithdrawal: mockProveAndFinalizeWithdrawal, + setIsWithdrawModalOpen: mockSetIsWithdrawModalOpen, + }); + + render(); + + expect(screen.getByText('Transaction denied')).toBeInTheDocument(); + }); + + it('calls waitForWithdrawal when withdrawStatus changes to withdrawSuccess', () => { + (useAppchainBridgeContext as Mock).mockReturnValue({ + withdrawStatus: 'withdrawSuccess', + waitForWithdrawal: mockWaitForWithdrawal, + proveAndFinalizeWithdrawal: mockProveAndFinalizeWithdrawal, + setIsWithdrawModalOpen: mockSetIsWithdrawModalOpen, + }); + + render(); + + expect(mockWaitForWithdrawal).toHaveBeenCalled(); + }); + + it('disables claim button when buttonDisabled is true', () => { + (useWithdrawButton as Mock).mockReturnValue({ + isSuccess: false, + buttonDisabled: true, + buttonContent: , + shouldShowClaim: true, + label: 'Claim', + }); + + render(); + + const claimButton = screen.getByRole('button'); + expect(claimButton).toHaveClass(pressable.disabled); + }); +}); diff --git a/src/appchain/bridge/components/AppchainBridgeWithdraw.tsx b/src/appchain/bridge/components/AppchainBridgeWithdraw.tsx new file mode 100644 index 0000000000..0e59f42105 --- /dev/null +++ b/src/appchain/bridge/components/AppchainBridgeWithdraw.tsx @@ -0,0 +1,117 @@ +import { Spinner } from '@/internal/components/Spinner'; +import { SuccessSvg } from '@/internal/svg/fullWidthSuccessSvg'; +import { border, cn, color, pressable, text } from '@/styles/theme'; +import { useEffect } from 'react'; +import { useWithdrawButton } from '../hooks/useWithdrawButton'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; + +export const AppchainBridgeWithdraw = () => { + const { + withdrawStatus, + waitForWithdrawal, + proveAndFinalizeWithdrawal, + setIsWithdrawModalOpen, + } = useAppchainBridgeContext(); + + const { isSuccess, buttonDisabled, buttonContent, shouldShowClaim, label } = + useWithdrawButton({ + withdrawStatus, + }); + + useEffect(() => { + (async () => { + if (withdrawStatus === 'withdrawSuccess') { + // If appchain withdrawal is successful, wait for claim to be ready + waitForWithdrawal(); + } + })(); + }, [withdrawStatus, waitForWithdrawal]); + + const buttonStyles = cn( + pressable.primary, + border.radius, + 'w-full rounded-xl', + 'px-4 py-3 font-medium text-base text-white leading-6', + text.headline, + ); + + const SuccessIcon = () => ( +
+
+ +
+
+ ); + + const LoadingContent = () => ( +
+ + + Waiting for claim to be ready... +
+ Please do not close this window. +
+
+ ); + + const SuccessContent = () => ( +
+ + +
+ ); + + const ClaimContent = () => ( +
+ + +
+ ); + + const renderContent = () => { + if (isSuccess) { + return ; + } + return shouldShowClaim ? : ; + }; + + return ( +
+
+
+

+ {label} +

+
+
+
+ {renderContent()} + {withdrawStatus === 'claimRejected' && ( +
+ Transaction denied +
+ )} +
+
+ ); +}; diff --git a/src/appchain/bridge/components/AppchainNetworkToggleButton.tsx b/src/appchain/bridge/components/AppchainNetworkToggleButton.tsx new file mode 100644 index 0000000000..b6010e4367 --- /dev/null +++ b/src/appchain/bridge/components/AppchainNetworkToggleButton.tsx @@ -0,0 +1,32 @@ +'use client'; +import { toggleSvg } from '@/internal/svg/toggleSvg'; +import { border, cn, pressable } from '@/styles/theme'; +import { useAppchainBridgeContext } from './AppchainBridgeProvider'; +interface AppchainNetworkToggleButtonReact { + className?: string; +} + +export function AppchainNetworkToggleButton({ + className, +}: AppchainNetworkToggleButtonReact) { + const { handleToggle } = useAppchainBridgeContext(); + + return ( + + ); +} diff --git a/src/appchain/bridge/constants.ts b/src/appchain/bridge/constants.ts index 348eb9834f..df53c6edf4 100644 --- a/src/appchain/bridge/constants.ts +++ b/src/appchain/bridge/constants.ts @@ -7,6 +7,7 @@ import { } from '@/token/constants'; import type { Address, Hex } from 'viem'; import { base, baseSepolia } from 'viem/chains'; +import type { BridgeableToken } from './types'; export const APPCHAIN_BRIDGE_ADDRESS = '0x4200000000000000000000000000000000000010'; @@ -28,6 +29,13 @@ export const USDC_BY_CHAIN: Record = { [baseSepolia.id]: usdcSepoliaToken, }; +export const DEFAULT_BRIDGEABLE_TOKENS = [ + { + ...ETH_BY_CHAIN[8453], + remoteToken: ETH_BY_CHAIN[8453].address, + } as BridgeableToken, +]; + export const MIN_GAS_LIMIT = 100000; export const EXTRA_DATA = '0x'; export const OUTPUT_ROOT_PROOF_VERSION = diff --git a/src/appchain/bridge/types.ts b/src/appchain/bridge/types.ts index 6b81baf5e8..30b43f6f41 100644 --- a/src/appchain/bridge/types.ts +++ b/src/appchain/bridge/types.ts @@ -55,6 +55,7 @@ export type AppchainBridgeContextType = { proveAndFinalizeWithdrawal: () => Promise; setIsAddressModalOpen: (open: boolean) => void; setIsWithdrawModalOpen: (open: boolean) => void; + resetDepositStatus: () => void; }; /** diff --git a/src/appchain/bridge/utils/defaultPriceFetcher.test.ts b/src/appchain/bridge/utils/defaultPriceFetcher.test.ts new file mode 100644 index 0000000000..4666c6db7d --- /dev/null +++ b/src/appchain/bridge/utils/defaultPriceFetcher.test.ts @@ -0,0 +1,77 @@ +import type { Token } from '@/token'; +import { base, baseSepolia } from 'viem/chains'; +import { describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import { USDC_BY_CHAIN } from '../constants'; +import { defaultPriceFetcher } from './defaultPriceFetcher'; +import { getETHPrice } from './getETHPrice'; + +vi.mock('./getETHPrice', () => ({ + getETHPrice: vi.fn(), +})); + +describe('defaultPriceFetcher', () => { + const mockETHToken: Token = { + name: 'Ethereum', + address: '', + symbol: 'ETH', + decimals: 18, + image: 'https://example.com/eth.png', + chainId: base.id, + }; + + const mockUSDCToken: Token = { + name: 'USD Coin', + address: USDC_BY_CHAIN[base.id].address, + symbol: 'USDC', + decimals: 6, + image: 'https://example.com/usdc.png', + chainId: base.id, + }; + + const mockSepoliaUSDCToken: Token = { + name: 'USD Coin', + address: USDC_BY_CHAIN[baseSepolia.id].address, + symbol: 'USDC', + decimals: 6, + image: 'https://example.com/usdc.png', + chainId: baseSepolia.id, + }; + + const mockOtherToken: Token = { + name: 'Other Token', + address: '0x123456789', + symbol: 'OTHER', + decimals: 18, + image: 'https://example.com/other.png', + chainId: base.id, + }; + + it('should return ETH price * amount for ETH token', async () => { + const mockETHPrice = '1800.50'; + (getETHPrice as Mock).mockResolvedValue(mockETHPrice); + + const result = await defaultPriceFetcher('2', mockETHToken); + + expect(getETHPrice).toHaveBeenCalled(); + expect(result).toBe('3601.00'); + }); + + it('should return same amount for Base USDC token', async () => { + const result = await defaultPriceFetcher('100', mockUSDCToken); + + expect(result).toBe('100.00'); + }); + + it('should return same amount for Sepolia USDC token', async () => { + const result = await defaultPriceFetcher('100', mockSepoliaUSDCToken); + + expect(result).toBe('100.00'); + }); + + it('should return empty string for other tokens', async () => { + const result = await defaultPriceFetcher('100', mockOtherToken); + + expect(result).toBe(''); + }); +}); diff --git a/src/appchain/bridge/utils/defaultPriceFetcher.ts b/src/appchain/bridge/utils/defaultPriceFetcher.ts new file mode 100644 index 0000000000..b881ca136f --- /dev/null +++ b/src/appchain/bridge/utils/defaultPriceFetcher.ts @@ -0,0 +1,19 @@ +import type { Token } from '@/token'; +import { base, baseSepolia } from 'viem/chains'; +import { USDC_BY_CHAIN } from '../constants'; +import { getETHPrice } from './getETHPrice'; + +export const defaultPriceFetcher = async (amount: string, token: Token) => { + if (!token.address) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + const price = await getETHPrice(); + return (Number(price) * Number(amount)).toFixed(2); + } + if ( + token.address === USDC_BY_CHAIN[base.id].address || + token.address === USDC_BY_CHAIN[baseSepolia.id].address + ) { + return (Number(amount) * 1).toFixed(2); + } + return ''; +}; diff --git a/src/internal/svg/fullWidthSuccessSvg.tsx b/src/internal/svg/fullWidthSuccessSvg.tsx new file mode 100644 index 0000000000..7739e0d676 --- /dev/null +++ b/src/internal/svg/fullWidthSuccessSvg.tsx @@ -0,0 +1,17 @@ +export const SuccessSvg = ({ fill = '#65A30D' }) => ( + + Success SVG + + +); From 3531a8e41146d220486c772ae83f907eead0c42c Mon Sep 17 00:00:00 2001 From: Alec Chen <93971719+0xAlec@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:40:34 -0500 Subject: [PATCH 2/3] custom gas token use bigint(0) add comment use `bridgeAddress` tests custom gas token --- src/appchain/bridge/abi.ts | 26 +++++++++ src/appchain/bridge/hooks/useDeposit.test.ts | 36 ++++++++++++ src/appchain/bridge/hooks/useDeposit.ts | 58 +++++++++++++------ src/appchain/bridge/hooks/useWithdraw.test.ts | 43 ++++++++++++++ src/appchain/bridge/hooks/useWithdraw.ts | 15 ++++- src/appchain/bridge/types.ts | 4 +- 6 files changed, 162 insertions(+), 20 deletions(-) diff --git a/src/appchain/bridge/abi.ts b/src/appchain/bridge/abi.ts index e3e9cc2d26..78415bb145 100644 --- a/src/appchain/bridge/abi.ts +++ b/src/appchain/bridge/abi.ts @@ -1117,3 +1117,29 @@ export const OptimismPortalABI = [ inputs: [], }, ] as const; + +export const L2_TO_L1_MESSAGE_PASSER_ABI = [ + { + type: 'function', + name: 'initiateWithdrawal', + inputs: [ + { + name: '_target', + type: 'address', + internalType: 'address', + }, + { + name: '_gasLimit', + type: 'uint256', + internalType: 'uint256', + }, + { + name: '_data', + type: 'bytes', + internalType: 'bytes', + }, + ], + outputs: [], + stateMutability: 'payable', + }, +] as const; diff --git a/src/appchain/bridge/hooks/useDeposit.test.ts b/src/appchain/bridge/hooks/useDeposit.test.ts index cab483cc06..9d8015ebb2 100644 --- a/src/appchain/bridge/hooks/useDeposit.test.ts +++ b/src/appchain/bridge/hooks/useDeposit.test.ts @@ -250,4 +250,40 @@ describe('useDeposit', () => { expect(result.current.depositStatus).toBe('error'); }); }); + + it('should handle custom gas token deposits correctly', async () => { + const { result } = renderHook(() => useDeposit(), { wrapper }); + await result.current.deposit({ + config: mockAppchainConfig, + from: mockChain, + bridgeParams: { + ...mockBridgeParams, + token: { ...mockBridgeParams.token, isCustomGasToken: true }, + }, + }); + + expect(mockWriteContractAsync).toHaveBeenCalledWith({ + abi: expect.any(Array), + functionName: 'approve', + args: [ + mockAppchainConfig.contracts.optimismPortal, + parseUnits(mockBridgeParams.amount, mockBridgeParams.token.decimals), + ], + address: mockBridgeParams.token.address, + }); + + expect(mockWriteContractAsync).toHaveBeenCalledWith({ + abi: expect.any(Array), + functionName: 'depositERC20Transaction', + args: [ + mockBridgeParams.recipient, + parseUnits(mockBridgeParams.amount, mockBridgeParams.token.decimals), + BigInt(0), + BigInt(100000), + false, + '0x', + ], + address: mockAppchainConfig.contracts.optimismPortal, + }); + }); }); diff --git a/src/appchain/bridge/hooks/useDeposit.ts b/src/appchain/bridge/hooks/useDeposit.ts index 3fc2f5b24d..2f35bb6757 100644 --- a/src/appchain/bridge/hooks/useDeposit.ts +++ b/src/appchain/bridge/hooks/useDeposit.ts @@ -1,9 +1,9 @@ import { useCallback, useState } from 'react'; import { parseEther, parseUnits } from 'viem'; -import type { Chain } from 'viem'; +import type { Chain, Hex } from 'viem'; import { useAccount, useConfig, useSwitchChain, useWriteContract } from 'wagmi'; import { waitForTransactionReceipt } from 'wagmi/actions'; -import { ERC20ABI, StandardBridgeABI } from '../abi'; +import { ERC20ABI, OptimismPortalABI, StandardBridgeABI } from '../abi'; import { EXTRA_DATA, MIN_GAS_LIMIT } from '../constants'; import type { AppchainConfig } from '../types'; import type { BridgeParams } from '../types'; @@ -67,11 +67,17 @@ export function useDeposit() { bridgeParams.amount, bridgeParams.token.decimals, ); + + // Bridge address is OptimismPortal for depositing custom gas tokens + const bridgeAddress = bridgeParams.token.isCustomGasToken + ? config.contracts.optimismPortal + : config.contracts.l1StandardBridge; + // Approve the L1StandardBridge to spend tokens const approveTx = await writeContractAsync({ abi: ERC20ABI, functionName: 'approve', - args: [config.contracts.l1StandardBridge, formattedAmount], + args: [bridgeAddress, formattedAmount], address: bridgeParams.token.address, }); await waitForTransactionReceipt(wagmiConfig, { @@ -80,23 +86,41 @@ export function useDeposit() { chainId: from.id, }); + let bridgeTxHash: Hex; + // Bridge the tokens - const bridgeTx = await writeContractAsync({ - abi: StandardBridgeABI, - functionName: 'bridgeERC20To', - args: [ - bridgeParams.token.address, - bridgeParams.token.remoteToken, // TODO: manually calculate the salted address - bridgeParams.recipient, - formattedAmount, - MIN_GAS_LIMIT, - EXTRA_DATA, - ], - address: config.contracts.l1StandardBridge, - }); + if (bridgeParams.token.isCustomGasToken) { + bridgeTxHash = await writeContractAsync({ + abi: OptimismPortalABI, + functionName: 'depositERC20Transaction', + args: [ + bridgeParams.recipient, + formattedAmount, + BigInt(0), + BigInt(MIN_GAS_LIMIT), + false, + EXTRA_DATA, + ], + address: bridgeAddress, + }); + } else { + bridgeTxHash = await writeContractAsync({ + abi: StandardBridgeABI, + functionName: 'bridgeERC20To', + args: [ + bridgeParams.token.address, + bridgeParams.token.remoteToken, // TODO: manually calculate the salted address + bridgeParams.recipient, + formattedAmount, + MIN_GAS_LIMIT, + EXTRA_DATA, + ], + address: bridgeAddress, + }); + } await waitForTransactionReceipt(wagmiConfig, { - hash: bridgeTx, + hash: bridgeTxHash, confirmations: 1, chainId: from.id, }); diff --git a/src/appchain/bridge/hooks/useWithdraw.test.ts b/src/appchain/bridge/hooks/useWithdraw.test.ts index 695dfdd023..56805101a7 100644 --- a/src/appchain/bridge/hooks/useWithdraw.test.ts +++ b/src/appchain/bridge/hooks/useWithdraw.test.ts @@ -8,6 +8,7 @@ import { useAccount, useConfig, useSwitchChain, useWriteContract } from 'wagmi'; import { readContract, waitForTransactionReceipt } from 'wagmi/actions'; import { APPCHAIN_BRIDGE_ADDRESS, + APPCHAIN_L2_TO_L1_MESSAGE_PASSER_ADDRESS, EXTRA_DATA, MIN_GAS_LIMIT, } from '../constants'; @@ -336,4 +337,46 @@ describe('useWithdraw', () => { expect(result.current.withdrawStatus).toBe('idle'); }); }); + + it('should handle custom ERC20 gas token withdrawal', async () => { + const customGasTokenBridgeParams = { + ...mockBridgeParams, + token: { + ...mockBridgeParams.token, + isCustomGasToken: true, + }, + } as BridgeParams; + + const { result } = renderHook( + () => + useWithdraw({ + config: mockAppchainConfig, + chain: mockChain, + bridgeParams: customGasTokenBridgeParams, + }), + { wrapper }, + ); + + await result.current.withdraw(); + await waitFor(() => { + expect(mockWriteContractAsync).toHaveBeenCalledWith({ + abi: expect.arrayContaining([ + expect.objectContaining({ + name: 'initiateWithdrawal', + type: 'function', + }), + ]), + functionName: 'initiateWithdrawal', + args: [ + customGasTokenBridgeParams.recipient, + BigInt(MIN_GAS_LIMIT), + EXTRA_DATA, + ], + address: APPCHAIN_L2_TO_L1_MESSAGE_PASSER_ADDRESS, + value: parseEther(customGasTokenBridgeParams.amount), + chainId: mockAppchainConfig.chainId, + }); + expect(result.current.withdrawStatus).toBe('withdrawSuccess'); + }); + }); }); diff --git a/src/appchain/bridge/hooks/useWithdraw.ts b/src/appchain/bridge/hooks/useWithdraw.ts index bfba9c2c55..1ac5cfa78c 100644 --- a/src/appchain/bridge/hooks/useWithdraw.ts +++ b/src/appchain/bridge/hooks/useWithdraw.ts @@ -17,6 +17,7 @@ import { } from 'wagmi/actions'; import { L2OutputOracleABI, + L2_TO_L1_MESSAGE_PASSER_ABI, OptimismPortalABI, StandardBridgeABI, } from '../abi'; @@ -83,8 +84,18 @@ export const useWithdraw = ({ let transactionHash: Hex = '0x'; - // Native ETH - if (bridgeParams.token.address === '') { + // Custom gas token + if (bridgeParams.token.isCustomGasToken) { + transactionHash = await writeContractAsync({ + abi: L2_TO_L1_MESSAGE_PASSER_ABI, + functionName: 'initiateWithdrawal', + args: [bridgeParams.recipient, BigInt(MIN_GAS_LIMIT), EXTRA_DATA], + address: APPCHAIN_L2_TO_L1_MESSAGE_PASSER_ADDRESS, + chainId: config.chainId, + value: parseEther(bridgeParams.amount), + }); + } else if (bridgeParams.token.address === '') { + // Native ETH transactionHash = await writeContractAsync({ abi: StandardBridgeABI, functionName: 'bridgeETHTo', diff --git a/src/appchain/bridge/types.ts b/src/appchain/bridge/types.ts index 30b43f6f41..e607476e76 100644 --- a/src/appchain/bridge/types.ts +++ b/src/appchain/bridge/types.ts @@ -15,7 +15,7 @@ export type Appchain = { * Note: exported as public Type */ export type AppchainBridgeReact = { - /** The source chain to bridge from. This should beBase or Base Sepolia. */ + /** The source chain to bridge from. This should be Base or Base Sepolia. */ chain: Chain; /** The appchain to bridge to. */ appchain: Appchain; @@ -64,6 +64,8 @@ export type AppchainBridgeContextType = { export type BridgeableToken = Token & { /** The address of the remote token on the appchain. */ remoteToken?: Address; + /** Optional boolean to indicate if the chain uses a custom gas token */ + isCustomGasToken?: boolean; }; /** From 5bfc35f20c3d99d754bf9c1d3fb0eceb8a15dedb Mon Sep 17 00:00:00 2001 From: Alec Chen <93971719+0xAlec@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:10:43 -0500 Subject: [PATCH 3/3] test --- package.json | 6 +++ .../components/AppProvider.tsx | 11 +++++ .../nextjs-app-router/components/Demo.tsx | 2 + .../components/DemoOptions.tsx | 8 +++- .../components/OnchainProviders.tsx | 4 +- .../components/custom-chains/B3.ts | 16 +++++++ .../components/custom-chains/LRDS.ts | 16 +++++++ .../components/demo/AppchainBridge.tsx | 46 +++++++++++++++++++ .../components/form/active-component.tsx | 3 ++ .../components/form/appchain-chain-id.tsx | 35 ++++++++++++++ .../components/form/chain.tsx | 23 ++++++++-- .../nextjs-app-router/onchainkit/package.json | 12 +++-- .../nextjs-app-router/types/onchainkit.ts | 1 + .../components/AppchainBridgeProvider.tsx | 26 +++++++---- src/appchain/index.ts | 19 ++++++++ 15 files changed, 209 insertions(+), 19 deletions(-) create mode 100644 playground/nextjs-app-router/components/custom-chains/B3.ts create mode 100644 playground/nextjs-app-router/components/custom-chains/LRDS.ts create mode 100644 playground/nextjs-app-router/components/demo/AppchainBridge.tsx create mode 100644 playground/nextjs-app-router/components/form/appchain-chain-id.tsx create mode 100644 src/appchain/index.ts diff --git a/package.json b/package.json index b1fbb26093..0f61c5692b 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,12 @@ "import": "./esm/api/index.js", "default": "./esm/api/index.js" }, + "./appchain": { + "types": "./esm/appchain/index.d.ts", + "module": "./esm/appchain/index.js", + "import": "./esm/appchain/index.js", + "default": "./esm/appchain/index.js" + }, "./buy": { "types": "./esm/buy/index.d.ts", "module": "./esm/buy/index.js", diff --git a/playground/nextjs-app-router/components/AppProvider.tsx b/playground/nextjs-app-router/components/AppProvider.tsx index fbcbf80ddf..d5e7c3b37c 100644 --- a/playground/nextjs-app-router/components/AppProvider.tsx +++ b/playground/nextjs-app-router/components/AppProvider.tsx @@ -41,6 +41,8 @@ type State = { isSponsored?: boolean; vaultAddress?: Address; setVaultAddress: (vaultAddress: Address) => void; + appChainChainId?: number; + setAppChainChainId: (appChainChainId: number) => void; }; export const defaultState: State = { @@ -53,6 +55,8 @@ export const defaultState: State = { setNFTToken: () => {}, setIsSponsored: () => {}, setVaultAddress: () => {}, + appChainChainId: 84532, + setAppChainChainId: () => {}, }; export const AppContext = createContext(defaultState); @@ -125,6 +129,11 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { defaultValue: '0x7BfA7C4f149E7415b73bdeDfe609237e29CBF34A', }); + const [appChainChainId, setAppChainChainId] = useStateWithStorage({ + key: 'appChainChainId', + parser: (v) => Number.parseInt(v), + }); + // Load initial values from localStorage useEffect(() => { const storedPaymasters = localStorage.getItem('paymasters'); @@ -170,6 +179,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { isSponsored, vaultAddress, setVaultAddress, + appChainChainId, + setAppChainChainId, }} > = { + [OnchainKitComponent.AppchainBridge]: AppchainBridgeDemo, [OnchainKitComponent.FundButton]: FundButtonDemo, [OnchainKitComponent.FundCard]: FundCardDemo, [OnchainKitComponent.Buy]: BuyDemo, diff --git a/playground/nextjs-app-router/components/DemoOptions.tsx b/playground/nextjs-app-router/components/DemoOptions.tsx index 85b4d70d27..ac4ba92b69 100644 --- a/playground/nextjs-app-router/components/DemoOptions.tsx +++ b/playground/nextjs-app-router/components/DemoOptions.tsx @@ -4,6 +4,7 @@ import { EarnOptions } from '@/components/form/earn-options'; import { PaymasterUrl } from '@/components/form/paymaster'; import { OnchainKitComponent } from '@/types/onchainkit'; import { ActiveComponent } from './form/active-component'; +import { AppchainChainId } from './form/appchain-chain-id'; import { Chain } from './form/chain'; import { CheckoutOptions } from './form/checkout-options'; import { IsSponsored } from './form/is-sponsored'; @@ -11,7 +12,6 @@ import { NFTOptions } from './form/nft-options'; import { SwapConfig } from './form/swap-config'; import { TransactionOptions } from './form/transaction-options'; import { WalletType } from './form/wallet-type'; - const COMMON_OPTIONS = [ ActiveComponent, ComponentMode, @@ -20,8 +20,12 @@ const COMMON_OPTIONS = [ ]; const COMPONENT_CONFIG: Partial< - Record React.JSX.Element)[]> + Record > = { + [OnchainKitComponent.AppchainBridge]: [ + () => , + AppchainChainId, + ], [OnchainKitComponent.Buy]: [Chain, PaymasterUrl, IsSponsored, SwapConfig], [OnchainKitComponent.Checkout]: [ Chain, diff --git a/playground/nextjs-app-router/components/OnchainProviders.tsx b/playground/nextjs-app-router/components/OnchainProviders.tsx index 4c23f303d1..49130f9b50 100644 --- a/playground/nextjs-app-router/components/OnchainProviders.tsx +++ b/playground/nextjs-app-router/components/OnchainProviders.tsx @@ -7,12 +7,14 @@ import { http, createConfig } from 'wagmi'; import { WagmiProvider } from 'wagmi'; import { base, baseSepolia } from 'wagmi/chains'; import { coinbaseWallet } from 'wagmi/connectors'; +import { LRDS_CHAIN } from './custom-chains/LRDS'; export const config = createConfig({ - chains: [base, baseSepolia], + chains: [base, baseSepolia, LRDS_CHAIN], transports: { [base.id]: http(), [baseSepolia.id]: http(), + [LRDS_CHAIN.id]: http(), }, ssr: true, connectors: [ diff --git a/playground/nextjs-app-router/components/custom-chains/B3.ts b/playground/nextjs-app-router/components/custom-chains/B3.ts new file mode 100644 index 0000000000..54b7f2103b --- /dev/null +++ b/playground/nextjs-app-router/components/custom-chains/B3.ts @@ -0,0 +1,16 @@ +import { defineChain } from 'viem'; + +export const B3_CHAIN = defineChain({ + id: 4087967037, + name: 'B3 Chain', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + rpcUrls: { + default: { + http: ['https://b3-sepolia-rpc.l3.base.org'], + }, + }, +} as const); diff --git a/playground/nextjs-app-router/components/custom-chains/LRDS.ts b/playground/nextjs-app-router/components/custom-chains/LRDS.ts new file mode 100644 index 0000000000..0a6a22e874 --- /dev/null +++ b/playground/nextjs-app-router/components/custom-chains/LRDS.ts @@ -0,0 +1,16 @@ +import { defineChain } from 'viem'; + +export const LRDS_CHAIN = defineChain({ + id: 288669036, + name: 'Blocklords', + nativeCurrency: { + name: 'Blocklords', + symbol: 'LRDS', + decimals: 18, + }, + rpcUrls: { + default: { + http: ['https://blocklords-sepolia-rpc.l3.base.org'], + }, + }, +}); diff --git a/playground/nextjs-app-router/components/demo/AppchainBridge.tsx b/playground/nextjs-app-router/components/demo/AppchainBridge.tsx new file mode 100644 index 0000000000..abea459154 --- /dev/null +++ b/playground/nextjs-app-router/components/demo/AppchainBridge.tsx @@ -0,0 +1,46 @@ +import { AppchainBridge } from '@coinbase/onchainkit/appchain'; +import type { BridgeableToken } from '@coinbase/onchainkit/appchain'; +import { useContext } from 'react'; +import { baseSepolia } from 'wagmi/chains'; +import { AppContext } from '../AppProvider'; +import { LRDS_CHAIN } from '../custom-chains/LRDS'; +export default function AppchainBridgeDemo() { + const { appChainChainId } = useContext(AppContext); + + if (!appChainChainId) { + return
AppchainChainId is not set
; + } + + const bridgeableTokens = [ + { + address: '0x9A7bE36dF8221F5a3971693f5d71d2c471785478', + remoteToken: '0x4200000000000000000000000000000000000006', + name: 'Blocklords', + symbol: 'LRDS', + decimals: 18, + chainId: 288669036, + image: 'https://onchainkit.xyz/favicon/48x48.png?v4-19-24', + isCustomGasToken: true, + }, + ] as BridgeableToken[]; + + return ( +
+
+ + ), + }} + bridgeableTokens={bridgeableTokens} + /> +
+
+ ); +} diff --git a/playground/nextjs-app-router/components/form/active-component.tsx b/playground/nextjs-app-router/components/form/active-component.tsx index bc585759e3..9afe79648b 100644 --- a/playground/nextjs-app-router/components/form/active-component.tsx +++ b/playground/nextjs-app-router/components/form/active-component.tsx @@ -69,6 +69,9 @@ export function ActiveComponent() { NFT Mint Card Default Earn + + Appchain Bridge +
diff --git a/playground/nextjs-app-router/components/form/appchain-chain-id.tsx b/playground/nextjs-app-router/components/form/appchain-chain-id.tsx new file mode 100644 index 0000000000..39fec3b3f7 --- /dev/null +++ b/playground/nextjs-app-router/components/form/appchain-chain-id.tsx @@ -0,0 +1,35 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type React from 'react'; +import { useContext, useState } from 'react'; +import { AppContext } from '../AppProvider'; + +export function AppchainChainId() { + const { appChainChainId, setAppChainChainId } = useContext(AppContext); + const [inputValue, setInputValue] = useState( + appChainChainId?.toString() ?? '', + ); + + const handleAppchainChainIdChange = ( + event: React.ChangeEvent, + ) => { + const value = event.target.value; + setInputValue(value); + setAppChainChainId(Number.parseInt(value)); + }; + + return ( +
+ + +
+ ); +} diff --git a/playground/nextjs-app-router/components/form/chain.tsx b/playground/nextjs-app-router/components/form/chain.tsx index 5072a81497..d4e8e941b3 100644 --- a/playground/nextjs-app-router/components/form/chain.tsx +++ b/playground/nextjs-app-router/components/form/chain.tsx @@ -9,7 +9,21 @@ import { import { useContext } from 'react'; import { AppContext } from '../AppProvider'; -export function Chain() { +interface ChainOption { + id: number; + name: string; +} + +const DEFAULT_CHAINS: ChainOption[] = [ + { id: 84532, name: 'Base Sepolia' }, + { id: 8453, name: 'Base' }, +]; + +interface ChainProps { + chains?: ChainOption[]; +} + +export function Chain({ chains = DEFAULT_CHAINS }: ChainProps) { const { chainId, setChainId } = useContext(AppContext); return ( @@ -25,8 +39,11 @@ export function Chain() { - Base Sepolia - Base + {chains.map((chain) => ( + + {chain.name} + + ))} diff --git a/playground/nextjs-app-router/onchainkit/package.json b/playground/nextjs-app-router/onchainkit/package.json index 501090f7a1..0f61c5692b 100644 --- a/playground/nextjs-app-router/onchainkit/package.json +++ b/playground/nextjs-app-router/onchainkit/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/onchainkit", - "version": "0.36.10", + "version": "0.36.11", "type": "module", "repository": "https://github.com/coinbase/onchainkit.git", "license": "MIT", @@ -42,8 +42,8 @@ "qrcode": "^1.5.4", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", - "viem": "^2.21.33", - "wagmi": "^2.12.24" + "viem": "^2.23.0", + "wagmi": "^2.14.11" }, "devDependencies": { "@biomejs/biome": "1.8.3", @@ -122,6 +122,12 @@ "import": "./esm/api/index.js", "default": "./esm/api/index.js" }, + "./appchain": { + "types": "./esm/appchain/index.d.ts", + "module": "./esm/appchain/index.js", + "import": "./esm/appchain/index.js", + "default": "./esm/appchain/index.js" + }, "./buy": { "types": "./esm/buy/index.d.ts", "module": "./esm/buy/index.js", diff --git a/playground/nextjs-app-router/types/onchainkit.ts b/playground/nextjs-app-router/types/onchainkit.ts index 34ada0b6de..2ce4b910df 100644 --- a/playground/nextjs-app-router/types/onchainkit.ts +++ b/playground/nextjs-app-router/types/onchainkit.ts @@ -1,4 +1,5 @@ export enum OnchainKitComponent { + AppchainBridge = 'appchain-bridge', FundButton = 'fund-button', FundCard = 'fund-card', Buy = 'buy', diff --git a/src/appchain/bridge/components/AppchainBridgeProvider.tsx b/src/appchain/bridge/components/AppchainBridgeProvider.tsx index 645c259e81..4f75e2cdb3 100644 --- a/src/appchain/bridge/components/AppchainBridgeProvider.tsx +++ b/src/appchain/bridge/components/AppchainBridgeProvider.tsx @@ -133,7 +133,20 @@ export const AppchainBridgeProvider = ({ : bridgeParams.token.remoteToken; let _balance: string; - if (tokenAddress) { + + if ( + !tokenAddress || + (direction === 'withdraw' && bridgeParams.token.isCustomGasToken) + ) { + const ethBalance = await getBalance(wagmiConfig, { + address, + chainId: from.id, + }); + _balance = toReadableAmount( + ethBalance.value.toString(), + ethBalance.decimals, + ); + } else { const erc20Balance = await readContract(wagmiConfig, { abi: erc20Abi, functionName: 'balanceOf', @@ -145,17 +158,10 @@ export const AppchainBridgeProvider = ({ erc20Balance.toString(), bridgeParams.token.decimals, ); - } else { - const ethBalance = await getBalance(wagmiConfig, { - address, - chainId: from.id, - }); - _balance = toReadableAmount( - ethBalance.value.toString(), - ethBalance.decimals, - ); } + console.log('balance', _balance); + setBalance(_balance); }, [address, direction, bridgeParams.token, from.id, wagmiConfig]); diff --git a/src/appchain/index.ts b/src/appchain/index.ts new file mode 100644 index 0000000000..0a91388ce3 --- /dev/null +++ b/src/appchain/index.ts @@ -0,0 +1,19 @@ +// 🌲☀🌲 +export { AppchainBridge } from './bridge/components/AppchainBridge'; +export { AppchainBridgeInput } from './bridge/components/AppchainBridgeInput'; +export { AppchainBridgeNetwork } from './bridge/components/AppchainBridgeNetwork'; +export { AppchainBridgeProvider } from './bridge/components/AppchainBridgeProvider'; +export { AppchainBridgeTransactionButton } from './bridge/components/AppchainBridgeTransactionButton'; +export { AppchainBridgeWithdraw } from './bridge/components/AppchainBridgeWithdraw'; +export { AppchainNetworkToggleButton } from './bridge/components/AppchainNetworkToggleButton'; +export { + type Appchain, + type AppchainBridgeReact, + type AppchainConfig, + type BridgeableToken, +} from './bridge/types'; +export { useChainConfig } from './bridge/hooks/useAppchainConfig'; +export { useDeposit } from './bridge/hooks/useDeposit'; +export { useWithdraw } from './bridge/hooks/useWithdraw'; +export { useDepositButton } from './bridge/hooks/useDepositButton'; +export { useWithdrawButton } from './bridge/hooks/useWithdrawButton';