From 47514d03e60fa7e02dad4f06493bd672ecce2d9b Mon Sep 17 00:00:00 2001 From: Christophe Date: Thu, 2 Apr 2026 19:05:03 +0000 Subject: [PATCH 01/11] poc: solana --- .../app/api/crosschain-transfers/constants.ts | 1 + .../src/app/api/crosschain-transfers/lifi.ts | 26 +- .../src/app/api/crosschain-transfers/utils.ts | 8 + .../App/useSyncConnectedChainToQueryParams.ts | 15 +- .../SolanaPocTransferPanel.test.tsx | 241 ++++++++++ .../TransferPanel/SolanaPocTransferPanel.tsx | 422 ++++++++++++++++++ .../TransferPanel/TransferPanel.tsx | 10 + .../src/components/common/atoms/Loader.tsx | 1 + .../src/hooks/useLifiCrossTransferRoute.ts | 3 +- .../src/hooks/useNetworks.ts | 46 +- .../token-bridge-sdk/SolanaTransferStarter.ts | 28 ++ .../src/types/ChainQueryParam.ts | 23 + .../src/util/chainUtils.ts | 1 + .../src/util/featureFlag.ts | 2 +- .../src/util/rpc/solana.ts | 6 + .../src/util/wagmi/getWagmiChain.ts | 21 + .../src/util/wagmi/setup.ts | 3 +- .../src/wallet/contexts/EvmWalletContext.tsx | 21 - .../wallet/contexts/SolanaWalletContext.tsx | 22 - .../hooks/__tests__/useWallets.test.tsx | 6 +- .../src/wallet/hooks/useBalance.ts | 36 ++ .../src/wallet/hooks/useSigners.ts | 31 ++ .../src/wallet/hooks/useTokenBalances.ts | 32 ++ .../src/wallet/hooks/useWallets.ts | 4 +- .../wallet/providers/EvmBalanceProvider.tsx | 87 ++++ .../wallet/providers/EvmSignerProvider.tsx | 52 +++ .../wallet/providers/EvmWalletProvider.tsx | 21 +- .../providers/SolanaBalanceProvider.tsx | 106 +++++ .../wallet/providers/SolanaSignerProvider.tsx | 71 +++ .../wallet/providers/SolanaWalletProvider.tsx | 21 +- .../src/wallet/providers/WalletProvider.tsx | 45 +- .../arb-token-bridge-ui/src/wallet/types.ts | 40 ++ 32 files changed, 1375 insertions(+), 77 deletions(-) create mode 100644 packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.test.tsx create mode 100644 packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.tsx create mode 100644 packages/arb-token-bridge-ui/src/token-bridge-sdk/SolanaTransferStarter.ts create mode 100644 packages/arb-token-bridge-ui/src/util/rpc/solana.ts delete mode 100644 packages/arb-token-bridge-ui/src/wallet/contexts/EvmWalletContext.tsx delete mode 100644 packages/arb-token-bridge-ui/src/wallet/contexts/SolanaWalletContext.tsx create mode 100644 packages/arb-token-bridge-ui/src/wallet/hooks/useBalance.ts create mode 100644 packages/arb-token-bridge-ui/src/wallet/hooks/useSigners.ts create mode 100644 packages/arb-token-bridge-ui/src/wallet/hooks/useTokenBalances.ts create mode 100644 packages/arb-token-bridge-ui/src/wallet/providers/EvmBalanceProvider.tsx create mode 100644 packages/arb-token-bridge-ui/src/wallet/providers/EvmSignerProvider.tsx create mode 100644 packages/arb-token-bridge-ui/src/wallet/providers/SolanaBalanceProvider.tsx create mode 100644 packages/arb-token-bridge-ui/src/wallet/providers/SolanaSignerProvider.tsx diff --git a/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/constants.ts b/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/constants.ts index a29149af7..5cca91e9b 100644 --- a/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/constants.ts +++ b/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/constants.ts @@ -12,6 +12,7 @@ export const lifiDestinationChainIds: Record = { [ChainId.ApeChain]: [ChainId.Ethereum, ChainId.ArbitrumOne, ChainId.Superposition], [ChainId.Superposition]: [ChainId.Ethereum, ChainId.ArbitrumOne, ChainId.ApeChain], [ChainId.Base]: [ChainId.ArbitrumOne, ChainId.ApeChain, ChainId.Superposition], + [ChainId.Solana]: [ChainId.ArbitrumOne], }; export const allowedLifiSourceChainIds: number[] = Object.keys(lifiDestinationChainIds).map((id) => diff --git a/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/lifi.ts b/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/lifi.ts index 370e69d64..a2589a9bb 100644 --- a/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/lifi.ts +++ b/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/lifi.ts @@ -8,6 +8,7 @@ import { StepToolDetails, createConfig, getRoutes, + isSVMAddress, } from '@lifi/sdk'; import { BigNumber, constants, utils } from 'ethers'; import { NextRequest, NextResponse } from 'next/server'; @@ -18,7 +19,7 @@ import { APE_TOKEN_LOGO, ETHER_TOKEN_LOGO, ether } from '../../../constants'; import { ChainId } from '../../../types/ChainId'; import { addressesEqual } from '../../../util/AddressUtils'; import { CrosschainTransfersRouteBase, QueryParams, Token } from './types'; -import { isValidLifiTransfer } from './utils'; +import { isValidLifiTransfer, solanaNativeTokenAddress } from './utils'; export const LIFI_INTEGRATOR_IDS = { NORMAL: '_arbitrum', @@ -356,14 +357,24 @@ export async function GET( try { // Validate parameters - if (!fromToken || !utils.isAddress(fromToken)) { + const isValidFromTokenAddress = + Number(fromChainId) === ChainId.Solana + ? fromToken === solanaNativeTokenAddress || (fromToken ? isSVMAddress(fromToken) : false) + : !!fromToken && utils.isAddress(fromToken); + + if (!isValidFromTokenAddress) { return NextResponse.json( { message: 'fromToken is not a valid address', data: null }, { status: 400 }, ); } - if (!toToken || !utils.isAddress(toToken)) { + const isValidToTokenAddress = + Number(toChainId) === ChainId.Solana + ? toToken === solanaNativeTokenAddress || (toToken ? isSVMAddress(toToken) : false) + : !!toToken && utils.isAddress(toToken); + + if (!isValidToTokenAddress) { return NextResponse.json( { message: 'toToken is not a valid address', data: null }, { status: 400 }, @@ -380,7 +391,7 @@ export async function GET( if ( !isValidLifiTransfer({ - fromToken, + fromToken: fromToken ?? undefined, sourceChainId: Number(fromChainId), destinationChainId: Number(toChainId), }) @@ -406,13 +417,16 @@ export async function GET( ); } + const normalizedFromToken = fromToken ?? ''; + const normalizedToToken = toToken ?? ''; + const parameters: RoutesRequest = { fromAddress, fromAmount, - fromTokenAddress: fromToken, + fromTokenAddress: normalizedFromToken, fromChainId: Number(fromChainId), toChainId: Number(toChainId), - toTokenAddress: toToken, + toTokenAddress: normalizedToToken, toAddress, }; diff --git a/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/utils.ts b/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/utils.ts index fa11935b3..596e94d83 100644 --- a/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/utils.ts +++ b/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/utils.ts @@ -9,6 +9,10 @@ import { addressesEqual } from '../../../util/AddressUtils'; import { CommonAddress, bridgedUsdcToken, commonUsdcToken } from '../../../util/CommonAddressUtils'; import { allowedLifiSourceChainIds, lifiDestinationChainIds } from './constants'; +export const solanaNativeTokenAddress = '11111111111111111111111111111111'; +export const solanaUsdcTokenAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +export const solanaUsdtTokenAddress = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'; + export function isLifiTransfer({ sourceChainId, destinationChainId, @@ -59,6 +63,10 @@ export function isValidLifiTransfer({ return true; } + if (sourceChainId === ChainId.Solana) { + return fromToken === solanaNativeTokenAddress; + } + if (!tokensFromLists) { return true; } diff --git a/packages/arb-token-bridge-ui/src/components/App/useSyncConnectedChainToQueryParams.ts b/packages/arb-token-bridge-ui/src/components/App/useSyncConnectedChainToQueryParams.ts index f3cdf7c84..ae63a4f0d 100644 --- a/packages/arb-token-bridge-ui/src/components/App/useSyncConnectedChainToQueryParams.ts +++ b/packages/arb-token-bridge-ui/src/components/App/useSyncConnectedChainToQueryParams.ts @@ -8,6 +8,7 @@ import { DisabledFeatures, useArbQueryParams } from '../../hooks/useArbQueryPara import { useDisabledFeatures } from '../../hooks/useDisabledFeatures'; import { sanitizeQueryParams } from '../../hooks/useNetworks'; import { getAccountType } from '../../util/AccountUtils'; +import { isSolanaEnabled } from '../../util/featureFlag'; import { getNetworkName } from '../../util/networks'; export function useSyncConnectedChainToQueryParams() { @@ -25,6 +26,8 @@ export function useSyncConnectedChainToQueryParams() { DisabledFeatures.TRANSFERS_TO_NON_ARBITRUM_CHAINS, ); + const shouldDisableConnectedChainSync = isSolanaEnabled(); + const setSourceChainToConnectedChain = useCallback(() => { if (!chain) { return; @@ -38,7 +41,7 @@ export function useSyncConnectedChainToQueryParams() { }); setQueryParams({ sourceChain, destinationChain }); - }, [chain, setQueryParams, disableTransfersToNonArbitrumChains]); + }, [chain, setQueryParams, disableTransfersToNonArbitrumChains, shouldDisableConnectedChainSync]); useEffect(() => { if (!chain || sourceChain === undefined || accountType !== 'smart-contract-wallet') { @@ -80,6 +83,10 @@ export function useSyncConnectedChainToQueryParams() { }, [accountType, chain, setSourceChainToConnectedChain, sourceChain]); useEffect(() => { + if (shouldDisableConnectedChainSync) { + return; + } + if (shouldSync) { return; } @@ -88,9 +95,13 @@ export function useSyncConnectedChainToQueryParams() { if (sourceChain === undefined && destinationChain === undefined) { setShouldSync(true); } - }, [shouldSync, sourceChain, destinationChain]); + }, [destinationChain, shouldDisableConnectedChainSync, shouldSync, sourceChain]); useEffect(() => { + if (shouldDisableConnectedChainSync) { + return; + } + // When the chain is connected and we should sync, and we haven't synced yet, sync the connected chain to the query params if (chain && shouldSync && !didSync) { setSourceChainToConnectedChain(); diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.test.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.test.tsx new file mode 100644 index 000000000..ce50b938e --- /dev/null +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.test.tsx @@ -0,0 +1,241 @@ +import { getStepTransaction } from '@lifi/sdk'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { BigNumber, constants } from 'ethers'; +import React, { PropsWithChildren } from 'react'; +import { SWRConfig } from 'swr'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + solanaNativeTokenAddress, + solanaUsdcTokenAddress, + solanaUsdtTokenAddress, +} from '../../app/api/crosschain-transfers/utils'; +import { useLifiCrossTransfersRoute } from '../../hooks/useLifiCrossTransferRoute'; +import { useNetworks } from '../../hooks/useNetworks'; +import { ChainId } from '../../types/ChainId'; +import { CommonAddress } from '../../util/CommonAddressUtils'; +import { createNetworksState } from '../../wallet/hooks/__tests__/utils'; +import { useWalletModal } from '../../wallet/hooks/useWalletModal'; +import { EvmBalanceContext } from '../../wallet/providers/EvmBalanceProvider'; +import { + EvmSignerContext, + defaultEvmSignerContextValue, +} from '../../wallet/providers/EvmSignerProvider'; +import { EvmWalletContext } from '../../wallet/providers/EvmWalletProvider'; +import { SolanaBalanceContext } from '../../wallet/providers/SolanaBalanceProvider'; +import { + SolanaSignerContext, + defaultSolanaSignerContextValue, +} from '../../wallet/providers/SolanaSignerProvider'; +import { SolanaWalletContext } from '../../wallet/providers/SolanaWalletProvider'; +import type { BalanceHandle, SignerHandle, WalletHandle } from '../../wallet/types'; +import { SolanaPocTransferPanel } from './SolanaPocTransferPanel'; + +vi.mock('@lifi/sdk', () => ({ + getStepTransaction: vi.fn(), +})); + +vi.mock('../../hooks/useNetworks', () => ({ + useNetworks: vi.fn(), +})); + +vi.mock('../../wallet/hooks/useWalletModal', () => ({ + useWalletModal: vi.fn(), +})); + +vi.mock('../../hooks/useLifiCrossTransferRoute', () => ({ + useLifiCrossTransfersRoute: vi.fn(), +})); + +const sourceWallet: WalletHandle = { + ecosystem: 'solana', + account: { + ecosystem: 'solana', + address: 'Hgw1pNJDYm5NbMheUHFNniiqtncor73swrH4RSN9APu5', + chain: { id: ChainId.Solana }, + status: 'connected', + }, + isConnected: true, + disconnect: async () => {}, +}; + +const destinationWallet: WalletHandle = { + ecosystem: 'evm', + account: { + ecosystem: 'evm', + address: '0x9481eF9e2CA814fc94676dEa3E8c3097B06b3a33', + chain: { id: ChainId.ArbitrumOne }, + status: 'connected', + }, + isConnected: true, + disconnect: async () => {}, +}; + +const route = { + protocolData: { + orders: ['CHEAPEST'], + tool: { + key: 'gasZipBridge', + name: 'GasZip', + logoURI: 'https://example.com/gaszip.svg', + }, + step: { + id: 'step-1', + }, + }, + toAmount: { + amount: '100000000000000000', + }, +} as const; + +type LifiRouteResult = ReturnType; +type GetStepTransactionResult = Awaited>; + +function createWrapper({ + evmWallet = destinationWallet, + solanaWallet = sourceWallet, + evmSigner = defaultEvmSignerContextValue, + solanaSigner = defaultSolanaSignerContextValue, + evmBalance, + solanaBalance, +}: { + evmWallet?: WalletHandle; + solanaWallet?: WalletHandle; + evmSigner?: SignerHandle; + solanaSigner?: SignerHandle; + evmBalance: BalanceHandle; + solanaBalance: BalanceHandle; +}) { + return function Wrapper({ children }: PropsWithChildren) { + return ( + new Map(), dedupingInterval: 0 }}> + + + + + + + {children} + + + + + + + + ); + }; +} + +describe('SolanaPocTransferPanel', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('shows connected wallets and balances, then executes through the injected source signer', async () => { + const sendTransaction = vi.fn().mockResolvedValue({ + hash: 'solana-hash', + wait: vi.fn().mockResolvedValue(undefined), + }); + const solanaFetchBalance = vi.fn().mockResolvedValue({ + [solanaNativeTokenAddress]: BigNumber.from('1250000000'), + [solanaUsdcTokenAddress]: BigNumber.from('42000000'), + [solanaUsdtTokenAddress]: BigNumber.from('7500000'), + }); + const evmFetchBalance = vi.fn().mockResolvedValue({ + [constants.AddressZero]: BigNumber.from('500000000000000000'), + [CommonAddress.ArbitrumOne.USDC]: BigNumber.from('11000000'), + [CommonAddress.ArbitrumOne.USDT]: BigNumber.from('9500000'), + }); + + vi.mocked(useNetworks).mockReturnValue([ + createNetworksState({ + sourceChainId: ChainId.Solana, + destinationChainId: ChainId.ArbitrumOne, + sourceChainName: 'Solana', + destinationChainName: 'Arbitrum One', + }), + vi.fn(), + ]); + vi.mocked(useWalletModal).mockReturnValue({ + openConnectModal: vi.fn(), + }); + vi.mocked(useLifiCrossTransfersRoute).mockReturnValue({ + data: [route], + error: undefined, + isLoading: false, + mutate: vi.fn(), + isValidating: false, + } as LifiRouteResult); + vi.mocked(getStepTransaction).mockResolvedValue({ + transactionRequest: { + data: 'serialized-solana-transaction', + }, + } as GetStepTransactionResult); + + render(, { + wrapper: createWrapper({ + evmBalance: { + ecosystem: 'evm', + fetchBalance: evmFetchBalance, + }, + solanaBalance: { + ecosystem: 'solana', + fetchBalance: solanaFetchBalance, + }, + evmSigner: defaultEvmSignerContextValue, + solanaSigner: { + ecosystem: 'solana', + sendTransaction, + }, + }), + }); + + expect(screen.getByRole('button', { name: 'Disconnect Source Wallet' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Disconnect Destination Wallet' })).toBeTruthy(); + expect(screen.getByText('Hgw1pN...APu5')).toBeTruthy(); + expect(screen.getByText('0x9481...3a33')).toBeTruthy(); + + await waitFor(() => { + expect(solanaFetchBalance).toHaveBeenCalledWith({ + chainId: ChainId.Solana, + walletAddress: sourceWallet.account.address, + tokenAddresses: [solanaNativeTokenAddress, solanaUsdcTokenAddress, solanaUsdtTokenAddress], + }); + expect(evmFetchBalance).toHaveBeenCalledWith({ + chainId: ChainId.ArbitrumOne, + walletAddress: destinationWallet.account.address, + tokenAddresses: [ + constants.AddressZero, + CommonAddress.ArbitrumOne.USDC, + CommonAddress.ArbitrumOne.USDT, + ], + }); + }); + + expect(await screen.findByText('1.25')).toBeTruthy(); + expect(await screen.findByText('42.0')).toBeTruthy(); + expect(await screen.findByText('7.5')).toBeTruthy(); + expect(await screen.findByText('0.5')).toBeTruthy(); + expect(await screen.findByText('11.0')).toBeTruthy(); + expect(await screen.findByText('9.5')).toBeTruthy(); + + fireEvent.change(screen.getByPlaceholderText('0.01'), { + target: { value: '0.25' }, + }); + + expect(screen.getByDisplayValue(destinationWallet.account.address as string)).toBeTruthy(); + + fireEvent.click(screen.getByTestId('solana-poc-execute-transfer')); + + await waitFor(() => { + expect(getStepTransaction).toHaveBeenCalledWith(route.protocolData.step); + expect(sendTransaction).toHaveBeenCalledWith({ + ecosystem: 'solana', + serializedTransaction: 'serialized-solana-transaction', + }); + }); + + expect(await screen.findByText('Last transaction: solana-hash')).toBeTruthy(); + }); +}); diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.tsx new file mode 100644 index 000000000..0679ea5cc --- /dev/null +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.tsx @@ -0,0 +1,422 @@ +import { getStepTransaction } from '@lifi/sdk'; +import { BigNumber, utils } from 'ethers'; +import React, { useEffect, useMemo, useState } from 'react'; +import { zeroAddress } from 'viem'; + +import type { + LifiCrosschainTransfersRoute, + Order, +} from '@/bridge/app/api/crosschain-transfers/lifi'; +import { ChainId } from '@/bridge/types/ChainId'; + +import { + solanaNativeTokenAddress, + solanaUsdcTokenAddress, + solanaUsdtTokenAddress, +} from '../../app/api/crosschain-transfers/utils'; +import { useLifiCrossTransfersRoute } from '../../hooks/useLifiCrossTransferRoute'; +import { useNetworks } from '../../hooks/useNetworks'; +import { SolanaTransferStarter } from '../../token-bridge-sdk/SolanaTransferStarter'; +import { CommonAddress } from '../../util/CommonAddressUtils'; +import { useSigners } from '../../wallet/hooks/useSigners'; +import { useTokenBalances } from '../../wallet/hooks/useTokenBalances'; +import { useWalletModal } from '../../wallet/hooks/useWalletModal'; +import { useWallets } from '../../wallet/hooks/useWallets'; +import { Button } from '../common/Button'; +import { Loader } from '../common/atoms/Loader'; + +type PocBridgeToken = { + symbol: string; + address: string; + decimals: number; + chainId: number; +}; + +const sourceBridgeTokens = [ + { + symbol: 'SOL', + address: solanaNativeTokenAddress, + decimals: 9, + chainId: ChainId.Solana, + }, + { + symbol: 'USDC', + address: solanaUsdcTokenAddress, + decimals: 6, + chainId: ChainId.Solana, + }, + { + symbol: 'USDT', + address: solanaUsdtTokenAddress, + decimals: 6, + chainId: ChainId.Solana, + }, +] as const satisfies readonly PocBridgeToken[]; + +const destinationBridgeTokens = [ + { + symbol: 'ETH', + address: zeroAddress, + decimals: 18, + chainId: ChainId.ArbitrumOne, + }, + { + symbol: 'USDC', + address: CommonAddress.ArbitrumOne.USDC, + decimals: 6, + chainId: ChainId.ArbitrumOne, + }, + { + symbol: 'USDT', + address: CommonAddress.ArbitrumOne.USDT, + decimals: 6, + chainId: ChainId.ArbitrumOne, + }, +] as const satisfies readonly PocBridgeToken[]; + +function truncateAddress(address?: string) { + if (!address) { + return 'Not connected'; + } + + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +function formatBalance(balance: BigNumber | undefined, decimals: number) { + if (!balance) { + return '0'; + } + + try { + return utils.formatUnits(balance, decimals); + } catch { + return '0'; + } +} + +function getPreferredRoute( + routes: LifiCrosschainTransfersRoute[] | undefined, +): LifiCrosschainTransfersRoute | undefined { + if (!routes?.length) { + return undefined; + } + + return ( + routes.find((route) => route.protocolData.orders.includes('CHEAPEST' as Order)) ?? routes[0] + ); +} + +export function SolanaPocTransferPanel() { + const sourceChainId = sourceBridgeTokens[0].chainId; + const destinationChainId = destinationBridgeTokens[0].chainId; + const [networks, setNetworks] = useNetworks(); + + useEffect(() => { + if ( + networks.sourceChain.id !== sourceChainId || + networks.destinationChain.id !== destinationChainId + ) { + setNetworks({ + sourceChainId, + destinationChainId, + }); + } + }, [ + destinationChainId, + networks.destinationChain.id, + networks.sourceChain.id, + setNetworks, + sourceChainId, + ]); + + const isPoCNetworkReady = + networks.sourceChain.id === sourceChainId && + networks.destinationChain.id === destinationChainId; + + if (!isPoCNetworkReady) { + return ( +
+
+

+ Solana Wallet Interactions PoC +

+

Initializing PoC networks...

+
+
+ ); + } + + return ( + + ); +} + +function SolanaPocTransferPanelContent({ + sourceChainId, + destinationChainId, +}: { + sourceChainId: number; + destinationChainId: number; +}) { + const { openConnectModal } = useWalletModal(); + const { sourceWallet, destinationWallet } = useWallets(); + const { sourceSigner } = useSigners(); + + const [amount, setAmount] = useState(''); + const [destinationAddress, setDestinationAddress] = useState(''); + const [hasEditedDestination, setHasEditedDestination] = useState(false); + const [isExecuting, setIsExecuting] = useState(false); + const [transactionHash, setTransactionHash] = useState(); + + useEffect(() => { + if (!hasEditedDestination && destinationWallet.account.address) { + setDestinationAddress(destinationWallet.account.address); + } + }, [destinationWallet.account.address, hasEditedDestination]); + + const isDestinationAddressValid = useMemo( + () => destinationAddress.length > 0 && utils.isAddress(destinationAddress), + [destinationAddress], + ); + + const fromAmount = useMemo(() => { + try { + return utils.parseUnits(amount || '0', 9).toString(); + } catch { + return '0'; + } + }, [amount]); + + const { data: sourceBalances, error: sourceBalanceError } = useTokenBalances({ + chainId: sourceChainId, + walletAddress: sourceWallet.account.address, + tokenAddresses: sourceBridgeTokens.map(({ address }) => address), + }); + + const { data: destinationBalances, error: destinationBalanceError } = useTokenBalances({ + chainId: destinationChainId, + walletAddress: destinationWallet.account.address, + tokenAddresses: destinationBridgeTokens.map(({ address }) => address), + }); + + const { + data: routes, + isLoading: isLoadingRoutes, + error: routesError, + } = useLifiCrossTransfersRoute({ + enabled: sourceWallet.isConnected && isDestinationAddressValid, + fromAmount, + fromToken: solanaNativeTokenAddress, + toToken: zeroAddress, + fromChainId: sourceChainId, + toChainId: destinationChainId, + fromAddress: sourceWallet.account.address, + toAddress: destinationAddress, + slippage: '0.003', + }); + + const selectedRoute = useMemo(() => getPreferredRoute(routes), [routes]); + + const routeHint = useMemo(() => { + if (routesError || isLoadingRoutes || selectedRoute) { + return null; + } + + if (!sourceWallet.isConnected) { + return 'Connect the source wallet to fetch a route.'; + } + + if (fromAmount === '0') { + return 'Enter an amount to fetch a route.'; + } + + if (!destinationAddress.length) { + return 'Enter a destination address to fetch a route.'; + } + + if (!isDestinationAddressValid) { + return 'Enter a valid Arbitrum One address.'; + } + + return 'No route available for the current inputs.'; + }, [ + destinationAddress.length, + fromAmount, + isDestinationAddressValid, + isLoadingRoutes, + routesError, + selectedRoute, + sourceWallet.isConnected, + ]); + + const executeTransfer = async () => { + if (!selectedRoute) { + return; + } + + setIsExecuting(true); + + try { + const { transactionRequest } = await getStepTransaction(selectedRoute.protocolData.step); + const serializedTransaction = transactionRequest?.data; + + if (!serializedTransaction) { + throw new Error('LI.FI did not return a Solana transaction payload.'); + } + + const starter = new SolanaTransferStarter(); + const result = await starter.transfer({ + signer: sourceSigner, + serializedTransaction, + }); + + setTransactionHash(result.hash); + await result.wait?.(); + } finally { + setIsExecuting(false); + } + }; + + return ( +
+
+

+ Solana Wallet Interactions PoC +

+

+ Hardcoded flow: native SOL on Solana to native ETH on Arbitrum One. +

+
+ +
+
+

Source Wallet

+

Source

+

{truncateAddress(sourceWallet.account.address)}

+ +
+ +
+

Destination Wallet

+

Destination

+

+ {truncateAddress(destinationWallet.account.address)} +

+ +
+
+ +
+
+

Source Balances

+ {sourceBalanceError ? ( +

{sourceBalanceError.message}

+ ) : ( + sourceBridgeTokens.map((token) => ( +
+ {token.symbol} + {formatBalance(sourceBalances?.[token.address], token.decimals)} +
+ )) + )} +
+ +
+

Destination Balances

+ {destinationBalanceError ? ( +

{destinationBalanceError.message}

+ ) : ( + destinationBridgeTokens.map((token) => ( +
+ {token.symbol} + {formatBalance(destinationBalances?.[token.address], token.decimals)} +
+ )) + )} +
+
+ +
+

Transfer

+ + + + + + {isLoadingRoutes ? ( +
+ + Fetching route... +
+ ) : null} + + {routesError ?

{routesError.message}

: null} + {routeHint ?

{routeHint}

: null} + + {selectedRoute ? ( +
+
Tool: {selectedRoute.protocolData.tool.name}
+
To amount: {utils.formatUnits(selectedRoute.toAmount.amount, 18)} ETH
+
+ ) : null} + + {transactionHash ? ( +

Last transaction: {transactionHash}

+ ) : null} + + +
+
+ ); +} diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.tsx index 8605e6255..a61cf3172 100644 --- a/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/TransferPanel.tsx @@ -52,6 +52,7 @@ import { stepGeneratorForCctp } from '../../ui-driver/UiDriverCctp'; import { addressesEqual } from '../../util/AddressUtils'; import { getLifiAssetType, trackEvent } from '../../util/AnalyticsUtils'; import { isGatewayRegistered, isTokenNativeUSDC } from '../../util/TokenUtils'; +import { isSolanaEnabled } from '../../util/featureFlag'; import { isUserRejectedError } from '../../util/isUserRejectedError'; import { isValidTransactionRequest } from '../../util/isValidTransactionRequest'; import { logger } from '../../util/logger'; @@ -72,6 +73,7 @@ import { getAmountLoss } from './HighSlippageWarningDialog'; import { MoveFundsButton } from './MoveFundsButton'; import { ReceiveFundsHeader } from './ReceiveFundsHeader'; import { Routes } from './Routes/Routes'; +import { SolanaPocTransferPanel } from './SolanaPocTransferPanel'; import { ToSConfirmationCheckbox } from './ToSConfirmationCheckbox'; import { TokenDepositCheckDialogType } from './TokenDepositCheckDialog'; import { TokenImportDialog, useTokenImportDialogStore } from './TokenImportDialog'; @@ -106,6 +108,14 @@ const networkConnectionWarningToast = () => ); export function TransferPanel() { + if (isSolanaEnabled()) { + return ; + } + + return ; +} + +function DefaultTransferPanel() { // Link the amount state directly to the amount in query params - no need of useState // Both `amount` getter and setter will internally be using `useArbQueryParams` functions const [ diff --git a/packages/arb-token-bridge-ui/src/components/common/atoms/Loader.tsx b/packages/arb-token-bridge-ui/src/components/common/atoms/Loader.tsx index 87a986010..f4387525c 100644 --- a/packages/arb-token-bridge-ui/src/components/common/atoms/Loader.tsx +++ b/packages/arb-token-bridge-ui/src/components/common/atoms/Loader.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { TailSpin } from 'react-loader-spinner'; import { BaseProps } from 'react-loader-spinner/dist/type'; diff --git a/packages/arb-token-bridge-ui/src/hooks/useLifiCrossTransferRoute.ts b/packages/arb-token-bridge-ui/src/hooks/useLifiCrossTransferRoute.ts index 5338dfb34..539fbbd2e 100644 --- a/packages/arb-token-bridge-ui/src/hooks/useLifiCrossTransferRoute.ts +++ b/packages/arb-token-bridge-ui/src/hooks/useLifiCrossTransferRoute.ts @@ -1,6 +1,5 @@ import { useDeferredValue, useMemo } from 'react'; import useSWR from 'swr'; -import { Address } from 'viem'; import { LifiCrosschainTransfersRoute, @@ -20,7 +19,7 @@ export type UseLifiCrossTransfersRouteParams = Pick< | 'denyExchanges' > & { enabled?: boolean; - toAddress: Address | undefined; + toAddress: string | undefined; fromChainId: number; toChainId: number; }; diff --git a/packages/arb-token-bridge-ui/src/hooks/useNetworks.ts b/packages/arb-token-bridge-ui/src/hooks/useNetworks.ts index 5f43dac44..18f86f425 100644 --- a/packages/arb-token-bridge-ui/src/hooks/useNetworks.ts +++ b/packages/arb-token-bridge-ui/src/hooks/useNetworks.ts @@ -27,6 +27,29 @@ export type UseNetworksSetStateParams = { }; export type UseNetworksSetState = (params: UseNetworksSetStateParams) => void; +function resolveNetworksState({ + sourceChainId, + destinationChainId, + disableTransfersToNonArbitrumChains, +}: { + sourceChainId: ChainId | number | undefined; + destinationChainId: ChainId | number | undefined; + disableTransfersToNonArbitrumChains: boolean; +}) { + if (sourceChainId === ChainId.Solana || destinationChainId === ChainId.Solana) { + return { + sourceChainId: sourceChainId ?? ChainId.Solana, + destinationChainId: destinationChainId ?? ChainId.ArbitrumOne, + }; + } + + return sanitizeQueryParams({ + sourceChainId, + destinationChainId, + disableTransfersToNonArbitrumChains, + }); +} + export function useNetworks(): [UseNetworksState, UseNetworksSetState] { const [{ sourceChain: sourceChainId, destinationChain: destinationChainId }, setQueryParams] = useArbQueryParams(); @@ -40,7 +63,7 @@ export function useNetworks(): [UseNetworksState, UseNetworksSetState] { const { sourceChainId: validSourceChainId, destinationChainId: validDestinationChainId } = useMemo( () => - sanitizeQueryParams({ + resolveNetworksState({ sourceChainId, destinationChainId, disableTransfersToNonArbitrumChains, @@ -68,7 +91,7 @@ export function useNetworks(): [UseNetworksState, UseNetworksSetState] { destinationChainId: newDestinationChainId, }: UseNetworksSetStateParams) => { const { sourceChainId: validSourceChainId, destinationChainId: validDestinationChainId } = - sanitizeQueryParams({ + resolveNetworksState({ sourceChainId: newSourceChainId, destinationChainId: newDestinationChainId, disableTransfersToNonArbitrumChains, @@ -83,12 +106,27 @@ export function useNetworks(): [UseNetworksState, UseNetworksSetState] { // The return values of the hook will always be the sanitized values return useMemo(() => { + const fallbackProviderChainId = + validSourceChainId === ChainId.Solana + ? validDestinationChainId === ChainId.Solana + ? ChainId.ArbitrumOne + : validDestinationChainId + : validSourceChainId; + const sourceChainProvider = + validSourceChainId === ChainId.Solana + ? getProviderForChainId(fallbackProviderChainId as ChainId) + : getProviderForChainId(validSourceChainId as ChainId); + const destinationChainProvider = + validDestinationChainId === ChainId.Solana + ? getProviderForChainId(validSourceChainId as ChainId) + : getProviderForChainId(validDestinationChainId as ChainId); + return [ { sourceChain: data.sourceChain, - sourceChainProvider: getProviderForChainId(validSourceChainId), + sourceChainProvider, destinationChain: data.destinationChain, - destinationChainProvider: getProviderForChainId(validDestinationChainId), + destinationChainProvider, }, setState, ]; diff --git a/packages/arb-token-bridge-ui/src/token-bridge-sdk/SolanaTransferStarter.ts b/packages/arb-token-bridge-ui/src/token-bridge-sdk/SolanaTransferStarter.ts new file mode 100644 index 000000000..66dd080f5 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/token-bridge-sdk/SolanaTransferStarter.ts @@ -0,0 +1,28 @@ +import type { + SendTransactionResult, + SignerHandle, + SolanaSendTransactionInput, +} from '../wallet/types'; + +type SolanaTransferProps = { + signer: SignerHandle; + serializedTransaction: string; +}; + +export class SolanaTransferStarter { + public async transfer({ + signer, + serializedTransaction, + }: SolanaTransferProps): Promise { + if (signer.ecosystem !== 'solana') { + throw new Error('Invalid signer: expected a Solana signer.'); + } + + const input: SolanaSendTransactionInput = { + ecosystem: 'solana', + serializedTransaction, + }; + + return signer.sendTransaction(input); + } +} diff --git a/packages/arb-token-bridge-ui/src/types/ChainQueryParam.ts b/packages/arb-token-bridge-ui/src/types/ChainQueryParam.ts index e20046061..9e2c52c2f 100644 --- a/packages/arb-token-bridge-ui/src/types/ChainQueryParam.ts +++ b/packages/arb-token-bridge-ui/src/types/ChainQueryParam.ts @@ -8,6 +8,7 @@ import * as customChains from '../util/wagmi/wagmiAdditionalNetworks'; import { chainToWagmiChain } from '../util/wagmi/wagmiAdditionalNetworks'; const chainQueryParams = [ + 'solana', 'ethereum', 'sepolia', 'arbitrum-one', @@ -36,6 +37,9 @@ export function isValidChainQueryParam(value: string | number): boolean { export function getChainQueryParamForChain(chainId: ChainId): ChainQueryParam { switch (chainId) { + case ChainId.Solana: + return 'solana'; + case ChainId.Ethereum: return 'ethereum'; @@ -85,6 +89,25 @@ export function getChainQueryParamForChain(chainId: ChainId): ChainQueryParam { export function getChainForChainKeyQueryParam(chainKeyQueryParam: ChainKeyQueryParam): Chain { switch (chainKeyQueryParam) { + case 'solana': + return { + id: ChainId.Solana, + name: 'Solana', + nativeCurrency: { + name: 'Solana', + symbol: 'SOL', + decimals: 9, + }, + rpcUrls: { + default: { + http: ['https://api.mainnet-beta.solana.com'], + }, + public: { + http: ['https://api.mainnet-beta.solana.com'], + }, + }, + } as Chain; + case 'ethereum': return chains.mainnet; diff --git a/packages/arb-token-bridge-ui/src/util/chainUtils.ts b/packages/arb-token-bridge-ui/src/util/chainUtils.ts index 148a034d1..becb6599b 100644 --- a/packages/arb-token-bridge-ui/src/util/chainUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/chainUtils.ts @@ -30,6 +30,7 @@ export function isSupportedChainId(chainId: ChainId | undefined): chainId is Cha const customChainIds = getCustomChainsFromLocalStorage().map((chain) => chain.chainId); return [ + ChainId.Solana, mainnet.id, sepolia.id, arbitrum.id, diff --git a/packages/arb-token-bridge-ui/src/util/featureFlag.ts b/packages/arb-token-bridge-ui/src/util/featureFlag.ts index 58fbd8a6f..df2b37d82 100644 --- a/packages/arb-token-bridge-ui/src/util/featureFlag.ts +++ b/packages/arb-token-bridge-ui/src/util/featureFlag.ts @@ -27,5 +27,5 @@ export function isOnrampServiceEnabled(service: (typeof full_onramp_services_lis } export function isSolanaEnabled() { - return process.env.NEXT_PUBLIC_FEATURE_FLAG_SOLANA_ENABLED === 'true'; + return true; } diff --git a/packages/arb-token-bridge-ui/src/util/rpc/solana.ts b/packages/arb-token-bridge-ui/src/util/rpc/solana.ts new file mode 100644 index 000000000..f6fc9eb1d --- /dev/null +++ b/packages/arb-token-bridge-ui/src/util/rpc/solana.ts @@ -0,0 +1,6 @@ +import { loadEnvironmentVariableWithFallback } from '../index'; + +export const solanaRpcUrl = loadEnvironmentVariableWithFallback({ + env: process.env.NEXT_PUBLIC_RPC_URL_SOLANA, + fallback: 'https://solana-rpc.publicnode.com', +}); diff --git a/packages/arb-token-bridge-ui/src/util/wagmi/getWagmiChain.ts b/packages/arb-token-bridge-ui/src/util/wagmi/getWagmiChain.ts index 939be653c..e5b0d6f6b 100644 --- a/packages/arb-token-bridge-ui/src/util/wagmi/getWagmiChain.ts +++ b/packages/arb-token-bridge-ui/src/util/wagmi/getWagmiChain.ts @@ -15,6 +15,24 @@ import { sepolia, } from './wagmiAdditionalNetworks'; +const solana = { + id: ChainId.Solana, + name: 'Solana', + nativeCurrency: { + name: 'Solana', + symbol: 'SOL', + decimals: 9, + }, + rpcUrls: { + default: { + http: ['https://api.mainnet-beta.solana.com'], + }, + public: { + http: ['https://api.mainnet-beta.solana.com'], + }, + }, +} as const satisfies Chain; + export function getWagmiChain(chainId: number): Chain { const customChain = getCustomChainFromLocalStorageById(chainId); const orbitChain = orbitChains[chainId]; @@ -28,6 +46,9 @@ export function getWagmiChain(chainId: number): Chain { } switch (chainId) { + case ChainId.Solana: + return solana; + case ChainId.Ethereum: return mainnet; diff --git a/packages/arb-token-bridge-ui/src/util/wagmi/setup.ts b/packages/arb-token-bridge-ui/src/util/wagmi/setup.ts index 1d17de41e..5ad8ce9b0 100644 --- a/packages/arb-token-bridge-ui/src/util/wagmi/setup.ts +++ b/packages/arb-token-bridge-ui/src/util/wagmi/setup.ts @@ -8,6 +8,7 @@ import { arbitrum, mainnet } from 'wagmi/chains'; import { unica } from '../../components/common/Font'; import { PORTAL_DOMAIN } from '../../constants'; import { isDevelopmentEnvironment, isE2eTestingEnvironment } from '../CommonUtils'; +import { isSolanaEnabled } from '../featureFlag'; import { logger } from '../logger'; import { getCustomChainsFromLocalStorage, initializeBridgeNetworks, rpcURLs } from '../networks'; import { getOrbitChains } from '../orbitChainsList'; @@ -53,7 +54,7 @@ function getChainList(): AppKitNetwork[] { } const chainList = getChainList(); -const solanaEnabled = process.env.NEXT_PUBLIC_FEATURE_FLAG_SOLANA_ENABLED === 'true'; +const solanaEnabled = isSolanaEnabled(); const projectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!; diff --git a/packages/arb-token-bridge-ui/src/wallet/contexts/EvmWalletContext.tsx b/packages/arb-token-bridge-ui/src/wallet/contexts/EvmWalletContext.tsx deleted file mode 100644 index 9fb1b5f37..000000000 --- a/packages/arb-token-bridge-ui/src/wallet/contexts/EvmWalletContext.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext } from 'react'; - -import type { WalletHandle } from '../types'; - -const defaultEvmWalletContextValue: WalletHandle = { - ecosystem: 'evm', - account: { - ecosystem: 'evm', - address: undefined, - chain: undefined, - status: 'disconnected', - }, - isConnected: false, - disconnect: async () => {}, -}; - -export const EvmWalletContext = createContext(defaultEvmWalletContextValue); - -export function useEvmWalletContext() { - return useContext(EvmWalletContext); -} diff --git a/packages/arb-token-bridge-ui/src/wallet/contexts/SolanaWalletContext.tsx b/packages/arb-token-bridge-ui/src/wallet/contexts/SolanaWalletContext.tsx deleted file mode 100644 index 0470b1c53..000000000 --- a/packages/arb-token-bridge-ui/src/wallet/contexts/SolanaWalletContext.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { createContext, useContext } from 'react'; - -import { ChainId } from '../../types/ChainId'; -import type { WalletHandle } from '../types'; - -export const defaultSolanaWalletContextValue: WalletHandle = { - ecosystem: 'solana', - account: { - ecosystem: 'solana', - address: undefined, - chain: { id: ChainId.Solana }, - status: 'disconnected', - }, - isConnected: false, - disconnect: async () => {}, -}; - -export const SolanaWalletContext = createContext(defaultSolanaWalletContextValue); - -export function useSolanaWalletContext() { - return useContext(SolanaWalletContext); -} diff --git a/packages/arb-token-bridge-ui/src/wallet/hooks/__tests__/useWallets.test.tsx b/packages/arb-token-bridge-ui/src/wallet/hooks/__tests__/useWallets.test.tsx index 8d3b3a086..90a8aef97 100644 --- a/packages/arb-token-bridge-ui/src/wallet/hooks/__tests__/useWallets.test.tsx +++ b/packages/arb-token-bridge-ui/src/wallet/hooks/__tests__/useWallets.test.tsx @@ -5,14 +5,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { useNetworks } from '../../../hooks/useNetworks'; import { ChainId } from '../../../types/ChainId'; import { isSolanaEnabled } from '../../../util/featureFlag'; -import { EvmWalletContext } from '../../contexts/EvmWalletContext'; +import { EvmWalletContext } from '../../providers/EvmWalletProvider'; import { SolanaWalletContext, defaultSolanaWalletContextValue, -} from '../../contexts/SolanaWalletContext'; +} from '../../providers/SolanaWalletProvider'; import type { WalletHandle } from '../../types'; -import { createNetworksState } from './utils'; import { useWallets } from '../useWallets'; +import { createNetworksState } from './utils'; vi.mock('../../../hooks/useNetworks', () => ({ useNetworks: vi.fn(), diff --git a/packages/arb-token-bridge-ui/src/wallet/hooks/useBalance.ts b/packages/arb-token-bridge-ui/src/wallet/hooks/useBalance.ts new file mode 100644 index 000000000..9f32de6c2 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/wallet/hooks/useBalance.ts @@ -0,0 +1,36 @@ +import { useCallback, useMemo } from 'react'; + +import { ChainId } from '../../types/ChainId'; +import { useEvmBalanceContext } from '../providers/EvmBalanceProvider'; +import { useSolanaBalanceContext } from '../providers/SolanaBalanceProvider'; + +type UseBalanceProps = { + chainId: number; +}; + +type FetchBalanceArgs = { + walletAddress: string; + tokenAddresses: string[]; +}; + +export function useBalance({ chainId }: UseBalanceProps) { + const evmBalance = useEvmBalanceContext(); + const solanaBalance = useSolanaBalanceContext(); + + const balanceProvider = useMemo( + () => (chainId === ChainId.Solana ? solanaBalance : evmBalance), + [chainId, evmBalance, solanaBalance], + ); + + const fetchBalance = useCallback( + ({ walletAddress, tokenAddresses }: FetchBalanceArgs) => + balanceProvider.fetchBalance({ + chainId, + walletAddress, + tokenAddresses, + }), + [balanceProvider, chainId], + ); + + return { fetchBalance }; +} diff --git a/packages/arb-token-bridge-ui/src/wallet/hooks/useSigners.ts b/packages/arb-token-bridge-ui/src/wallet/hooks/useSigners.ts new file mode 100644 index 000000000..35cddc0d1 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/wallet/hooks/useSigners.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; + +import { useNetworks } from '../../hooks/useNetworks'; +import { ChainId } from '../../types/ChainId'; +import { useEvmSignerContext } from '../providers/EvmSignerProvider'; +import { useSolanaSignerContext } from '../providers/SolanaSignerProvider'; +import type { SignerHandle } from '../types'; + +export function useSigners(): { + sourceSigner: SignerHandle; + destinationSigner: SignerHandle; +} { + const [networks] = useNetworks(); + const evmSigner = useEvmSignerContext(); + const solanaSigner = useSolanaSignerContext(); + + const sourceSigner = useMemo( + () => (networks.sourceChain.id === ChainId.Solana ? solanaSigner : evmSigner), + [evmSigner, networks.sourceChain.id, solanaSigner], + ); + + const destinationSigner = useMemo( + () => (networks.destinationChain.id === ChainId.Solana ? solanaSigner : evmSigner), + [evmSigner, networks.destinationChain.id, solanaSigner], + ); + + return { + sourceSigner, + destinationSigner, + }; +} diff --git a/packages/arb-token-bridge-ui/src/wallet/hooks/useTokenBalances.ts b/packages/arb-token-bridge-ui/src/wallet/hooks/useTokenBalances.ts new file mode 100644 index 000000000..72896f056 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/wallet/hooks/useTokenBalances.ts @@ -0,0 +1,32 @@ +import useSWR from 'swr'; + +import { useBalance } from './useBalance'; + +type UseTokenBalancesProps = { + chainId: number; + walletAddress?: string; + tokenAddresses: string[]; +}; + +export function useTokenBalances({ + chainId, + walletAddress, + tokenAddresses, +}: UseTokenBalancesProps) { + const { fetchBalance } = useBalance({ chainId }); + + const { data, error, isLoading } = useSWR( + walletAddress ? ['token-balances', chainId, walletAddress, ...tokenAddresses] : null, + ([, , keyWalletAddress, ...keyTokenAddresses]) => + fetchBalance({ + walletAddress: keyWalletAddress, + tokenAddresses: keyTokenAddresses, + }), + ); + + return { + data, + error, + isLoading, + }; +} diff --git a/packages/arb-token-bridge-ui/src/wallet/hooks/useWallets.ts b/packages/arb-token-bridge-ui/src/wallet/hooks/useWallets.ts index df62d8f59..f55f26c30 100644 --- a/packages/arb-token-bridge-ui/src/wallet/hooks/useWallets.ts +++ b/packages/arb-token-bridge-ui/src/wallet/hooks/useWallets.ts @@ -3,8 +3,8 @@ import { useCallback, useMemo } from 'react'; import { useNetworks } from '../../hooks/useNetworks'; import { ChainId } from '../../types/ChainId'; import { isSolanaEnabled } from '../../util/featureFlag'; -import { useEvmWalletContext } from '../contexts/EvmWalletContext'; -import { useSolanaWalletContext } from '../contexts/SolanaWalletContext'; +import { useEvmWalletContext } from '../providers/EvmWalletProvider'; +import { useSolanaWalletContext } from '../providers/SolanaWalletProvider'; import { WalletHandle } from '../types'; export function useWallets(): { diff --git a/packages/arb-token-bridge-ui/src/wallet/providers/EvmBalanceProvider.tsx b/packages/arb-token-bridge-ui/src/wallet/providers/EvmBalanceProvider.tsx new file mode 100644 index 000000000..fdab65f07 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/wallet/providers/EvmBalanceProvider.tsx @@ -0,0 +1,87 @@ +import { MultiCaller } from '@arbitrum/sdk'; +import { constants, utils } from 'ethers'; +import type { PropsWithChildren } from 'react'; +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { zeroAddress } from 'viem'; + +import { getProviderForChainId } from '../../token-bridge-sdk/utils'; +import { addressesEqual } from '../../util/AddressUtils'; +import type { BalanceHandle, FetchBalanceInput, FetchBalanceResult } from '../types'; + +export const defaultEvmBalanceContextValue: BalanceHandle = { + ecosystem: 'evm', + fetchBalance: async () => { + throw new Error('EVM balance provider is not available.'); + }, +}; + +export const EvmBalanceContext = createContext(defaultEvmBalanceContextValue); + +export function useEvmBalanceContext() { + return useContext(EvmBalanceContext); +} + +export function EvmBalanceProvider({ children }: PropsWithChildren) { + const fetchBalance = useCallback( + async ({ + chainId, + walletAddress, + tokenAddresses, + }: FetchBalanceInput): Promise => { + if (!utils.isAddress(walletAddress)) { + throw new Error('Invalid EVM wallet address.'); + } + + const provider = getProviderForChainId(chainId); + const balances = tokenAddresses.reduce((acc, tokenAddress) => { + acc[tokenAddress] = constants.Zero; + return acc; + }, {}); + + const nativeTokenAddresses = tokenAddresses.filter((tokenAddress) => + addressesEqual(tokenAddress, zeroAddress), + ); + const erc20TokenAddresses = tokenAddresses.filter( + (tokenAddress) => !addressesEqual(tokenAddress, zeroAddress), + ); + + const [nativeBalance, tokenData] = await Promise.all([ + nativeTokenAddresses.length > 0 + ? provider.getBalance(walletAddress) + : Promise.resolve(null), + erc20TokenAddresses.length > 0 + ? MultiCaller.fromProvider(provider).then((multiCaller) => + multiCaller.getTokenData(erc20TokenAddresses, { + balanceOf: { account: walletAddress }, + }), + ) + : Promise.resolve(null), + ]); + + if (nativeBalance) { + nativeTokenAddresses.forEach((tokenAddress) => { + balances[tokenAddress] = nativeBalance; + }); + } + + if (tokenData) { + erc20TokenAddresses.forEach((tokenAddress, index) => { + balances[tokenAddress] = tokenData[index]?.balance ?? constants.Zero; + }); + } + + return balances; + }, + [], + ); + + const value = useMemo( + () => ({ + ecosystem: 'evm', + fetchBalance, + }), + [fetchBalance], + ); + + return {children}; +} diff --git a/packages/arb-token-bridge-ui/src/wallet/providers/EvmSignerProvider.tsx b/packages/arb-token-bridge-ui/src/wallet/providers/EvmSignerProvider.tsx new file mode 100644 index 000000000..81ef7a734 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/wallet/providers/EvmSignerProvider.tsx @@ -0,0 +1,52 @@ +import type { Provider as ReownEvmProvider } from '@reown/appkit-utils/ethers'; +import { useAppKitProvider } from '@reown/appkit/react'; +import { providers } from 'ethers'; +import type { PropsWithChildren } from 'react'; +import { createContext, useCallback, useContext, useMemo } from 'react'; + +import type { EvmSendTransactionInput, SendTransactionResult, SignerHandle } from '../types'; + +export const defaultEvmSignerContextValue: SignerHandle = { + ecosystem: 'evm', + sendTransaction: async () => { + throw new Error('EVM signer is not connected.'); + }, +}; + +export const EvmSignerContext = createContext(defaultEvmSignerContextValue); + +export function useEvmSignerContext() { + return useContext(EvmSignerContext); +} + +export function EvmSignerProvider({ children }: PropsWithChildren) { + const { walletProvider } = useAppKitProvider('eip155'); + + const sendTransaction = useCallback( + async (input: EvmSendTransactionInput): Promise => { + if (!walletProvider) { + throw new Error('EVM signer is not connected.'); + } + + const provider = new providers.Web3Provider(walletProvider); + const signer = provider.getSigner(); + const tx = await signer.sendTransaction(input.txRequest); + + return { + hash: tx.hash, + wait: tx.wait, + }; + }, + [walletProvider], + ); + + const value = useMemo( + () => ({ + ecosystem: 'evm', + sendTransaction, + }), + [sendTransaction], + ); + + return {children}; +} diff --git a/packages/arb-token-bridge-ui/src/wallet/providers/EvmWalletProvider.tsx b/packages/arb-token-bridge-ui/src/wallet/providers/EvmWalletProvider.tsx index f2a108fd3..84085a553 100644 --- a/packages/arb-token-bridge-ui/src/wallet/providers/EvmWalletProvider.tsx +++ b/packages/arb-token-bridge-ui/src/wallet/providers/EvmWalletProvider.tsx @@ -1,10 +1,27 @@ import { useAppKitAccount, useAppKitNetwork, useDisconnect } from '@reown/appkit/react'; import type { PropsWithChildren } from 'react'; -import { useCallback, useMemo } from 'react'; +import { createContext, useCallback, useContext, useMemo } from 'react'; -import { EvmWalletContext } from '../contexts/EvmWalletContext'; import type { WalletAccount, WalletHandle } from '../types'; +const defaultEvmWalletContextValue: WalletHandle = { + ecosystem: 'evm', + account: { + ecosystem: 'evm', + address: undefined, + chain: undefined, + status: 'disconnected', + }, + isConnected: false, + disconnect: async () => {}, +}; + +export const EvmWalletContext = createContext(defaultEvmWalletContextValue); + +export function useEvmWalletContext() { + return useContext(EvmWalletContext); +} + export function EvmWalletProvider({ children }: PropsWithChildren) { const { address, isConnected, status } = useAppKitAccount({ namespace: 'eip155' }); const { chainId } = useAppKitNetwork(); diff --git a/packages/arb-token-bridge-ui/src/wallet/providers/SolanaBalanceProvider.tsx b/packages/arb-token-bridge-ui/src/wallet/providers/SolanaBalanceProvider.tsx new file mode 100644 index 000000000..116a29bdb --- /dev/null +++ b/packages/arb-token-bridge-ui/src/wallet/providers/SolanaBalanceProvider.tsx @@ -0,0 +1,106 @@ +import { Connection, PublicKey } from '@solana/web3.js'; +import { BigNumber, constants } from 'ethers'; +import type { PropsWithChildren } from 'react'; +import { createContext, useCallback, useContext, useMemo } from 'react'; + +import { solanaNativeTokenAddress } from '../../app/api/crosschain-transfers/utils'; +import { ChainId } from '../../types/ChainId'; +import { solanaRpcUrl } from '../../util/rpc/solana'; +import type { BalanceHandle, FetchBalanceInput, FetchBalanceResult } from '../types'; + +const solanaConnection = new Connection(solanaRpcUrl, 'confirmed'); +const splTokenProgramId = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); +const token2022ProgramId = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'); + +export const defaultSolanaBalanceContextValue: BalanceHandle = { + ecosystem: 'solana', + fetchBalance: async () => { + throw new Error('Solana balance provider is not available.'); + }, +}; + +export const SolanaBalanceContext = createContext(defaultSolanaBalanceContextValue); + +export function useSolanaBalanceContext() { + return useContext(SolanaBalanceContext); +} + +type JsonParsedTokenAccountInfo = { + mint: string; + tokenAmount: { + amount: string; + }; +}; + +async function getParsedTokenAccountsByOwnerForProgram( + ownerAddress: PublicKey, + programId: PublicKey, +) { + return solanaConnection.getParsedTokenAccountsByOwner(ownerAddress, { programId }, 'confirmed'); +} + +export function SolanaBalanceProvider({ children }: PropsWithChildren) { + const fetchBalance = useCallback( + async ({ + chainId, + walletAddress, + tokenAddresses, + }: FetchBalanceInput): Promise => { + if (chainId !== ChainId.Solana) { + throw new Error('Solana balance provider only supports Solana.'); + } + + const ownerAddress = new PublicKey(walletAddress); + const balances = tokenAddresses.reduce((acc, tokenAddress) => { + acc[tokenAddress] = constants.Zero; + return acc; + }, {}); + + const nativeTokenAddresses = tokenAddresses.filter( + (tokenAddress) => tokenAddress === solanaNativeTokenAddress, + ); + const splTokenAddresses = tokenAddresses.filter( + (tokenAddress) => tokenAddress !== solanaNativeTokenAddress, + ); + + if (nativeTokenAddresses.length > 0) { + const nativeBalance = await solanaConnection.getBalance(ownerAddress, 'confirmed'); + + nativeTokenAddresses.forEach((tokenAddress) => { + balances[tokenAddress] = BigNumber.from(nativeBalance.toString()); + }); + } + + if (splTokenAddresses.length > 0) { + const requestedTokenAddresses = new Set(splTokenAddresses); + const [splResponse, token2022Response] = await Promise.all([ + getParsedTokenAccountsByOwnerForProgram(ownerAddress, splTokenProgramId), + getParsedTokenAccountsByOwnerForProgram(ownerAddress, token2022ProgramId), + ]); + + [...splResponse.value, ...token2022Response.value].forEach((tokenAccount) => { + const tokenInfo = tokenAccount.account.data.parsed.info as JsonParsedTokenAccountInfo; + + if (!requestedTokenAddresses.has(tokenInfo.mint)) { + return; + } + + balances[tokenInfo.mint] = balances[tokenInfo.mint].add(tokenInfo.tokenAmount.amount); + }); + } + + return balances; + }, + [], + ); + + const value = useMemo( + () => ({ + ecosystem: 'solana', + fetchBalance, + }), + [fetchBalance], + ); + + return {children}; +} diff --git a/packages/arb-token-bridge-ui/src/wallet/providers/SolanaSignerProvider.tsx b/packages/arb-token-bridge-ui/src/wallet/providers/SolanaSignerProvider.tsx new file mode 100644 index 000000000..691dc2b63 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/wallet/providers/SolanaSignerProvider.tsx @@ -0,0 +1,71 @@ +import type { Provider as ReownSolanaProvider } from '@reown/appkit-utils/solana'; +import { useAppKitProvider } from '@reown/appkit/react'; +import { Connection, VersionedTransaction } from '@solana/web3.js'; +import type { PropsWithChildren } from 'react'; +import { createContext, useCallback, useContext, useMemo } from 'react'; + +import { solanaRpcUrl } from '../../util/rpc/solana'; +import type { SendTransactionResult, SignerHandle, SolanaSendTransactionInput } from '../types'; + +export const defaultSolanaSignerContextValue: SignerHandle = { + ecosystem: 'solana', + sendTransaction: async () => { + throw new Error('Solana signer is not connected.'); + }, +}; + +export const SolanaSignerContext = createContext(defaultSolanaSignerContextValue); + +const solanaConnection = new Connection(solanaRpcUrl, 'confirmed'); + +export function useSolanaSignerContext() { + return useContext(SolanaSignerContext); +} + +function decodeBase64(value: string): Uint8Array { + const binaryValue = atob(value); + return Uint8Array.from(binaryValue, (character) => character.charCodeAt(0)); +} + +export function SolanaSignerProvider({ children }: PropsWithChildren) { + const { walletProvider } = useAppKitProvider('solana'); + + const sendTransaction = useCallback( + async (input: SolanaSendTransactionInput): Promise => { + if (!walletProvider?.signAndSendTransaction) { + throw new Error('Solana signer is not connected.'); + } + + const transaction = VersionedTransaction.deserialize( + decodeBase64(input.serializedTransaction), + ); + const hash = await walletProvider.signAndSendTransaction(transaction, { + preflightCommitment: 'confirmed', + }); + + return { + hash, + wait: async () => { + const result = await solanaConnection.confirmTransaction(hash, 'confirmed'); + + if (result.value.err) { + throw new Error(`Solana transaction failed: ${JSON.stringify(result.value.err)}`); + } + + return result.value; + }, + }; + }, + [walletProvider], + ); + + const value = useMemo( + () => ({ + ecosystem: 'solana', + sendTransaction, + }), + [sendTransaction], + ); + + return {children}; +} diff --git a/packages/arb-token-bridge-ui/src/wallet/providers/SolanaWalletProvider.tsx b/packages/arb-token-bridge-ui/src/wallet/providers/SolanaWalletProvider.tsx index 2ba5b093b..829adade9 100644 --- a/packages/arb-token-bridge-ui/src/wallet/providers/SolanaWalletProvider.tsx +++ b/packages/arb-token-bridge-ui/src/wallet/providers/SolanaWalletProvider.tsx @@ -1,11 +1,28 @@ import { useAppKitAccount, useDisconnect } from '@reown/appkit/react'; import type { PropsWithChildren } from 'react'; -import { useCallback, useMemo } from 'react'; +import { createContext, useCallback, useContext, useMemo } from 'react'; import { ChainId } from '../../types/ChainId'; -import { SolanaWalletContext } from '../contexts/SolanaWalletContext'; import type { WalletAccount, WalletHandle } from '../types'; +export const defaultSolanaWalletContextValue: WalletHandle = { + ecosystem: 'solana', + account: { + ecosystem: 'solana', + address: undefined, + chain: { id: ChainId.Solana }, + status: 'disconnected', + }, + isConnected: false, + disconnect: async () => {}, +}; + +export const SolanaWalletContext = createContext(defaultSolanaWalletContextValue); + +export function useSolanaWalletContext() { + return useContext(SolanaWalletContext); +} + export function SolanaWalletProvider({ children }: PropsWithChildren) { const { address, isConnected, status } = useAppKitAccount({ namespace: 'solana' }); const { disconnect } = useDisconnect(); diff --git a/packages/arb-token-bridge-ui/src/wallet/providers/WalletProvider.tsx b/packages/arb-token-bridge-ui/src/wallet/providers/WalletProvider.tsx index 8a2672c63..6d56f2e67 100644 --- a/packages/arb-token-bridge-ui/src/wallet/providers/WalletProvider.tsx +++ b/packages/arb-token-bridge-ui/src/wallet/providers/WalletProvider.tsx @@ -4,11 +4,20 @@ import React, { Suspense, lazy } from 'react'; import type { PropsWithChildren } from 'react'; import { isSolanaEnabled } from '../../util/featureFlag'; -import { - SolanaWalletContext, - defaultSolanaWalletContextValue, -} from '../contexts/SolanaWalletContext'; +import { EvmBalanceProvider } from './EvmBalanceProvider'; +import { EvmSignerProvider } from './EvmSignerProvider'; import { EvmWalletProvider } from './EvmWalletProvider'; +import { + SolanaBalanceContext, + SolanaBalanceProvider, + defaultSolanaBalanceContextValue, +} from './SolanaBalanceProvider'; +import { + SolanaSignerContext, + SolanaSignerProvider, + defaultSolanaSignerContextValue, +} from './SolanaSignerProvider'; +import { SolanaWalletContext, defaultSolanaWalletContextValue } from './SolanaWalletProvider'; const LazySolanaWalletProvider = lazy(() => import('./SolanaWalletProvider').then((module) => ({ @@ -19,21 +28,39 @@ const LazySolanaWalletProvider = lazy(() => function SolanaFallbackProvider({ children }: PropsWithChildren) { return ( - {children} + + + {children} + + ); } export function WalletProvider({ children }: PropsWithChildren) { if (!isSolanaEnabled()) { - return {children}; + return ( + + + {children} + + + ); } return ( - {children}}> - {children} - + + + {children}}> + + + {children} + + + + + ); } diff --git a/packages/arb-token-bridge-ui/src/wallet/types.ts b/packages/arb-token-bridge-ui/src/wallet/types.ts index be5032e7f..c78f80666 100644 --- a/packages/arb-token-bridge-ui/src/wallet/types.ts +++ b/packages/arb-token-bridge-ui/src/wallet/types.ts @@ -1,9 +1,49 @@ +import type { TransactionRequest } from '@ethersproject/providers'; import type { UseAppKitAccountReturn } from '@reown/appkit/react'; +import type { BigNumber } from 'ethers'; export type WalletEcosystem = 'evm' | 'solana'; export type WalletStatus = NonNullable; +export type SendTransactionResult = { + hash: string; + wait?: () => Promise; +}; + +export type FetchBalanceResult = Record; + +export type FetchBalanceInput = { + chainId: number; + walletAddress: string; + tokenAddresses: string[]; +}; + +export type EvmSendTransactionInput = { + ecosystem: 'evm'; + txRequest: TransactionRequest; +}; + +export type SolanaSendTransactionInput = { + ecosystem: 'solana'; + serializedTransaction: string; +}; + +export type SignerHandle = + | { + ecosystem: 'evm'; + sendTransaction: (input: EvmSendTransactionInput) => Promise; + } + | { + ecosystem: 'solana'; + sendTransaction: (input: SolanaSendTransactionInput) => Promise; + }; + +export type BalanceHandle = { + ecosystem: WalletEcosystem; + fetchBalance: (input: FetchBalanceInput) => Promise; +}; + export interface WalletAccount { address?: string; chain?: { From dc73b24e80a0ebd2e31f7a3e90de4bf8aeaaeb2f Mon Sep 17 00:00:00 2001 From: Christophe Date: Thu, 2 Apr 2026 19:24:26 +0000 Subject: [PATCH 02/11] fix build --- packages/app/next.config.js | 4 ++++ .../src/wallet/providers/SolanaBalanceProvider.tsx | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/app/next.config.js b/packages/app/next.config.js index 7182d4610..a4ee9ce09 100644 --- a/packages/app/next.config.js +++ b/packages/app/next.config.js @@ -14,6 +14,10 @@ module.exports = { externalDir: true, }, webpack: (config) => { + config.resolve.alias = { + ...config.resolve.alias, + '@react-native-async-storage/async-storage': false, + }; config.externals.push('pino-pretty', 'lokijs', 'encoding', '@duneanalytics/client-sdk'); // pnpm's strict isolation can cause packages to resolve their own copy // of context-dependent libraries, breaking React context sharing. diff --git a/packages/arb-token-bridge-ui/src/wallet/providers/SolanaBalanceProvider.tsx b/packages/arb-token-bridge-ui/src/wallet/providers/SolanaBalanceProvider.tsx index 116a29bdb..4f96944a4 100644 --- a/packages/arb-token-bridge-ui/src/wallet/providers/SolanaBalanceProvider.tsx +++ b/packages/arb-token-bridge-ui/src/wallet/providers/SolanaBalanceProvider.tsx @@ -85,7 +85,9 @@ export function SolanaBalanceProvider({ children }: PropsWithChildren) { return; } - balances[tokenInfo.mint] = balances[tokenInfo.mint].add(tokenInfo.tokenAmount.amount); + balances[tokenInfo.mint] = (balances[tokenInfo.mint] ?? constants.Zero).add( + tokenInfo.tokenAmount.amount, + ); }); } From 54eed7fc0966990bd5be96bcce81e8ec9d5db260 Mon Sep 17 00:00:00 2001 From: Christophe Date: Thu, 2 Apr 2026 19:42:32 +0000 Subject: [PATCH 03/11] fix wallet buttons --- .../SolanaPocTransferPanel.test.tsx | 18 ++++-- .../TransferPanel/SolanaPocTransferPanel.tsx | 64 ++++++++++++++----- .../wallet/providers/EvmWalletProvider.tsx | 11 +++- .../wallet/providers/SolanaWalletProvider.tsx | 6 +- .../arb-token-bridge-ui/src/wallet/types.ts | 2 + 5 files changed, 76 insertions(+), 25 deletions(-) diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.test.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.test.tsx index ce50b938e..84c4b1f93 100644 --- a/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.test.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.test.tsx @@ -54,6 +54,9 @@ const sourceWallet: WalletHandle = { address: 'Hgw1pNJDYm5NbMheUHFNniiqtncor73swrH4RSN9APu5', chain: { id: ChainId.Solana }, status: 'connected', + walletInfo: { + name: 'Phantom', + }, }, isConnected: true, disconnect: async () => {}, @@ -66,6 +69,9 @@ const destinationWallet: WalletHandle = { address: '0x9481eF9e2CA814fc94676dEa3E8c3097B06b3a33', chain: { id: ChainId.ArbitrumOne }, status: 'connected', + walletInfo: { + name: 'MetaMask', + }, }, isConnected: true, disconnect: async () => {}, @@ -191,10 +197,14 @@ describe('SolanaPocTransferPanel', () => { }), }); - expect(screen.getByRole('button', { name: 'Disconnect Source Wallet' })).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Disconnect Destination Wallet' })).toBeTruthy(); - expect(screen.getByText('Hgw1pN...APu5')).toBeTruthy(); - expect(screen.getByText('0x9481...3a33')).toBeTruthy(); + expect(screen.getByText('Solana Wallet')).toBeTruthy(); + expect(screen.getByText('EVM Wallet')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Hgw1pN...APu5' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '0x9481...3a33' })).toBeTruthy(); + expect(screen.getByText('Phantom')).toBeTruthy(); + expect(screen.getByText('MetaMask')).toBeTruthy(); + expect(screen.getAllByText('Hgw1pN...APu5')).toHaveLength(2); + expect(screen.getAllByText('0x9481...3a33')).toHaveLength(2); await waitFor(() => { expect(solanaFetchBalance).toHaveBeenCalledWith({ diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.tsx b/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.tsx index 0679ea5cc..6dff9bb8e 100644 --- a/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/SolanaPocTransferPanel.tsx @@ -23,6 +23,7 @@ import { useTokenBalances } from '../../wallet/hooks/useTokenBalances'; import { useWalletModal } from '../../wallet/hooks/useWalletModal'; import { useWallets } from '../../wallet/hooks/useWallets'; import { Button } from '../common/Button'; +import { SafeImage } from '../common/SafeImage'; import { Loader } from '../common/atoms/Loader'; type PocBridgeToken = { @@ -94,6 +95,19 @@ function formatBalance(balance: BigNumber | undefined, decimals: number) { } } +function WalletButtonLabel({ icon, label }: { icon?: string; label: string }) { + return ( + + {icon ? ( + + ) : ( + + )} + {label} + + ); +} + function getPreferredRoute( routes: LifiCrosschainTransfersRoute[] | undefined, ): LifiCrosschainTransfersRoute | undefined { @@ -164,6 +178,8 @@ function SolanaPocTransferPanelContent({ const { openConnectModal } = useWalletModal(); const { sourceWallet, destinationWallet } = useWallets(); const { sourceSigner } = useSigners(); + const solanaWallet = sourceWallet.ecosystem === 'solana' ? sourceWallet : destinationWallet; + const evmWallet = sourceWallet.ecosystem === 'evm' ? sourceWallet : destinationWallet; const [amount, setAmount] = useState(''); const [destinationAddress, setDestinationAddress] = useState(''); @@ -172,10 +188,10 @@ function SolanaPocTransferPanelContent({ const [transactionHash, setTransactionHash] = useState(); useEffect(() => { - if (!hasEditedDestination && destinationWallet.account.address) { - setDestinationAddress(destinationWallet.account.address); + if (!hasEditedDestination && evmWallet.account.address) { + setDestinationAddress(evmWallet.account.address); } - }, [destinationWallet.account.address, hasEditedDestination]); + }, [evmWallet.account.address, hasEditedDestination]); const isDestinationAddressValid = useMemo( () => destinationAddress.length > 0 && utils.isAddress(destinationAddress), @@ -293,38 +309,52 @@ function SolanaPocTransferPanelContent({
-

Source Wallet

-

Source

-

{truncateAddress(sourceWallet.account.address)}

+

Solana Wallet

+

+ {solanaWallet.account.walletInfo?.name ?? 'Unknown wallet'} +

+

{truncateAddress(solanaWallet.account.address)}

-

Destination Wallet

-

Destination

+

EVM Wallet

- {truncateAddress(destinationWallet.account.address)} + {evmWallet.account.walletInfo?.name ?? 'Unknown wallet'}

+

{truncateAddress(evmWallet.account.address)}

diff --git a/packages/arb-token-bridge-ui/src/wallet/providers/EvmWalletProvider.tsx b/packages/arb-token-bridge-ui/src/wallet/providers/EvmWalletProvider.tsx index 84085a553..6a4dbab80 100644 --- a/packages/arb-token-bridge-ui/src/wallet/providers/EvmWalletProvider.tsx +++ b/packages/arb-token-bridge-ui/src/wallet/providers/EvmWalletProvider.tsx @@ -1,4 +1,9 @@ -import { useAppKitAccount, useAppKitNetwork, useDisconnect } from '@reown/appkit/react'; +import { + useAppKitAccount, + useAppKitNetwork, + useDisconnect, + useWalletInfo, +} from '@reown/appkit/react'; import type { PropsWithChildren } from 'react'; import { createContext, useCallback, useContext, useMemo } from 'react'; @@ -25,6 +30,7 @@ export function useEvmWalletContext() { export function EvmWalletProvider({ children }: PropsWithChildren) { const { address, isConnected, status } = useAppKitAccount({ namespace: 'eip155' }); const { chainId } = useAppKitNetwork(); + const { walletInfo } = useWalletInfo('eip155'); const { disconnect } = useDisconnect(); const account = useMemo(() => { @@ -33,8 +39,9 @@ export function EvmWalletProvider({ children }: PropsWithChildren) { address, chain: chainId === undefined ? undefined : { id: chainId as number }, status: status ?? 'disconnected', + walletInfo, }; - }, [address, chainId, status]); + }, [address, chainId, status, walletInfo]); const handleDisconnect = useCallback(async () => { if (!isConnected) { diff --git a/packages/arb-token-bridge-ui/src/wallet/providers/SolanaWalletProvider.tsx b/packages/arb-token-bridge-ui/src/wallet/providers/SolanaWalletProvider.tsx index 829adade9..3e3ecdd87 100644 --- a/packages/arb-token-bridge-ui/src/wallet/providers/SolanaWalletProvider.tsx +++ b/packages/arb-token-bridge-ui/src/wallet/providers/SolanaWalletProvider.tsx @@ -1,4 +1,4 @@ -import { useAppKitAccount, useDisconnect } from '@reown/appkit/react'; +import { useAppKitAccount, useDisconnect, useWalletInfo } from '@reown/appkit/react'; import type { PropsWithChildren } from 'react'; import { createContext, useCallback, useContext, useMemo } from 'react'; @@ -25,6 +25,7 @@ export function useSolanaWalletContext() { export function SolanaWalletProvider({ children }: PropsWithChildren) { const { address, isConnected, status } = useAppKitAccount({ namespace: 'solana' }); + const { walletInfo } = useWalletInfo('solana'); const { disconnect } = useDisconnect(); const account = useMemo(() => { @@ -35,8 +36,9 @@ export function SolanaWalletProvider({ children }: PropsWithChildren) { address, chain: { id: ChainId.Solana }, status: walletStatus, + walletInfo, }; - }, [address, status]); + }, [address, status, walletInfo]); const handleDisconnect = useCallback(async () => { if (!isConnected) { diff --git a/packages/arb-token-bridge-ui/src/wallet/types.ts b/packages/arb-token-bridge-ui/src/wallet/types.ts index c78f80666..114f2157e 100644 --- a/packages/arb-token-bridge-ui/src/wallet/types.ts +++ b/packages/arb-token-bridge-ui/src/wallet/types.ts @@ -1,4 +1,5 @@ import type { TransactionRequest } from '@ethersproject/providers'; +import type { ConnectedWalletInfo } from '@reown/appkit-controllers'; import type { UseAppKitAccountReturn } from '@reown/appkit/react'; import type { BigNumber } from 'ethers'; @@ -51,6 +52,7 @@ export interface WalletAccount { }; ecosystem: WalletEcosystem; status: WalletStatus; + walletInfo?: ConnectedWalletInfo; } export interface WalletHandle { From 0e998d22d27d2738ee112579863d4455b6d77bc3 Mon Sep 17 00:00:00 2001 From: Christophe Date: Tue, 19 May 2026 10:51:47 +0000 Subject: [PATCH 04/11] Cleanup --- .../AppShell/providers/AppProviders.tsx | 4 +- .../src/components/Widget/WidgetHeaderRow.tsx | 7 +- .../Widget/WidgetTransactionHistory.tsx | 8 +- .../components/Widget/WidgetTransferPanel.tsx | 6 +- .../src/hooks/useAccountMenu.ts | 31 +- pnpm-lock.yaml | 282 +++++++++++++++++- 6 files changed, 297 insertions(+), 41 deletions(-) diff --git a/packages/app/src/components/AppShell/providers/AppProviders.tsx b/packages/app/src/components/AppShell/providers/AppProviders.tsx index 61b8041f7..b62f913e3 100644 --- a/packages/app/src/components/AppShell/providers/AppProviders.tsx +++ b/packages/app/src/components/AppShell/providers/AppProviders.tsx @@ -52,7 +52,9 @@ export function AppProviders({ children }: PropsWithChildren) { - {children} + + {children} + diff --git a/packages/arb-token-bridge-ui/src/components/Widget/WidgetHeaderRow.tsx b/packages/arb-token-bridge-ui/src/components/Widget/WidgetHeaderRow.tsx index 70ca6c9c6..b3e8e1dfc 100644 --- a/packages/arb-token-bridge-ui/src/components/Widget/WidgetHeaderRow.tsx +++ b/packages/arb-token-bridge-ui/src/components/Widget/WidgetHeaderRow.tsx @@ -1,5 +1,4 @@ -import { useAccount } from 'wagmi'; - +import { useWallets } from '../../wallet/hooks/useWallets'; import { LifiSettingsButton } from '../TransferPanel/LifiSettingsButton'; import { Button } from '../common/Button'; import { OpenDialogFunction } from '../common/Dialog2'; @@ -12,7 +11,7 @@ type WidgetHeaderRowProps = { }; export function WidgetHeaderRow({ openDialog }: WidgetHeaderRowProps) { - const { isConnected } = useAccount(); + const { sourceWallet } = useWallets(); return (
@@ -22,7 +21,7 @@ export function WidgetHeaderRow({ openDialog }: WidgetHeaderRowProps) { {/* widget transaction history */} - {isConnected && ( + {sourceWallet.isConnected && ( +
+ ); +} + +function BalanceCard({ + balances, + chainId, + error, + label, + tokens, +}: { + balances: FetchBalanceResult | undefined; + chainId: PocChainId; + error: Error | undefined; + label: string; + tokens: readonly PocBridgeToken[]; +}) { return ( - - {icon ? ( - +
+

+ {label} Balances ({chainConfigs[chainId].label}) +

+ {error ? ( +

{error.message}

) : ( - + tokens.map((token) => ( +
+ {token.symbol} + {formatBalance(balances?.[token.address], token.decimals)} +
+ )) )} - {label} -
+
); } @@ -120,32 +199,67 @@ function getPreferredRoute( ); } -export function SolanaPocTransferPanel() { - const sourceChainId = sourceBridgeTokens[0].chainId; - const destinationChainId = destinationBridgeTokens[0].chainId; +function getEvmTransactionPayload(transactionRequest: unknown): TransactionRequest { + if (!transactionRequest) { + throw new Error('EVM transaction payload is missing.'); + } + + return transactionRequest as TransactionRequest; +} + +function getSolanaTransactionPayload(transactionRequest: unknown): string { + if ( + typeof transactionRequest !== 'object' || + transactionRequest === null || + !('data' in transactionRequest) || + typeof transactionRequest.data !== 'string' + ) { + throw new Error('Solana transaction payload is missing.'); + } + + return transactionRequest.data; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Failed to execute transfer.'; +} + +export function SolanaPocTransferPanel({ + initialPresetId = pocTransferPresets[0].id, +}: { + initialPresetId?: PocTransferPresetId; +}) { + const [selectedPresetId, setSelectedPresetId] = useState(initialPresetId); + + const selectedPreset = useMemo( + () => + pocTransferPresets.find((preset) => preset.id === selectedPresetId) ?? pocTransferPresets[0], + [selectedPresetId], + ); + const [networks, setNetworks] = useNetworks(); useEffect(() => { if ( - networks.sourceChain.id !== sourceChainId || - networks.destinationChain.id !== destinationChainId + networks.sourceChain.id !== selectedPreset.sourceChainId || + networks.destinationChain.id !== selectedPreset.destinationChainId ) { setNetworks({ - sourceChainId, - destinationChainId, + sourceChainId: selectedPreset.sourceChainId, + destinationChainId: selectedPreset.destinationChainId, }); } }, [ - destinationChainId, networks.destinationChain.id, networks.sourceChain.id, + selectedPreset.destinationChainId, + selectedPreset.sourceChainId, setNetworks, - sourceChainId, ]); const isPoCNetworkReady = - networks.sourceChain.id === sourceChainId && - networks.destinationChain.id === destinationChainId; + networks.sourceChain.id === selectedPreset.sourceChainId && + networks.destinationChain.id === selectedPreset.destinationChainId; if (!isPoCNetworkReady) { return ( @@ -162,60 +276,56 @@ export function SolanaPocTransferPanel() { return ( ); } function SolanaPocTransferPanelContent({ - sourceChainId, - destinationChainId, + selectedPreset, + onSelectPreset, }: { - sourceChainId: number; - destinationChainId: number; + selectedPreset: PocTransferPreset; + onSelectPreset: (presetId: PocTransferPresetId) => void; }) { const { openConnectModal } = useWalletModal(); const { sourceWallet, destinationWallet } = useWallets(); - const { sourceSigner } = useSigners(); - const solanaWallet = sourceWallet.ecosystem === 'solana' ? sourceWallet : destinationWallet; - const evmWallet = sourceWallet.ecosystem === 'evm' ? sourceWallet : destinationWallet; + const wagmiConfig = useConfig(); + const { sourceChainId, destinationChainId } = selectedPreset; + const sourceConfig = chainConfigs[sourceChainId]; + const destinationConfig = chainConfigs[destinationChainId]; + const sourceTokens = sourceConfig.tokens; + const destinationTokens = destinationConfig.tokens; + const fromToken = getFirstBridgeToken(sourceChainId); + const toToken = getFirstBridgeToken(destinationChainId); const [amount, setAmount] = useState(''); - const [destinationAddress, setDestinationAddress] = useState(''); - const [hasEditedDestination, setHasEditedDestination] = useState(false); + const [destinationAddressOverride, setDestinationAddressOverride] = useState(); const [isExecuting, setIsExecuting] = useState(false); + const [executionError, setExecutionError] = useState(); const [transactionHash, setTransactionHash] = useState(); - - useEffect(() => { - if (!hasEditedDestination && evmWallet.account.address) { - setDestinationAddress(evmWallet.account.address); - } - }, [evmWallet.account.address, hasEditedDestination]); - - const isDestinationAddressValid = useMemo( - () => destinationAddress.length > 0 && utils.isAddress(destinationAddress), - [destinationAddress], - ); + const destinationAddress = destinationAddressOverride ?? destinationWallet.account.address ?? ''; const fromAmount = useMemo(() => { try { - return utils.parseUnits(amount || '0', 9).toString(); + return utils.parseUnits(amount || '0', fromToken.decimals).toString(); } catch { return '0'; } - }, [amount]); + }, [amount, fromToken.decimals]); const { data: sourceBalances, error: sourceBalanceError } = useTokenBalances({ chainId: sourceChainId, walletAddress: sourceWallet.account.address, - tokenAddresses: sourceBridgeTokens.map(({ address }) => address), + tokenAddresses: sourceTokens.map(({ address }) => address), }); const { data: destinationBalances, error: destinationBalanceError } = useTokenBalances({ chainId: destinationChainId, walletAddress: destinationWallet.account.address, - tokenAddresses: destinationBridgeTokens.map(({ address }) => address), + tokenAddresses: destinationTokens.map(({ address }) => address), }); const { @@ -223,50 +333,21 @@ function SolanaPocTransferPanelContent({ isLoading: isLoadingRoutes, error: routesError, } = useLifiCrossTransfersRoute({ - enabled: sourceWallet.isConnected && isDestinationAddressValid, + enabled: sourceWallet.isConnected && fromAmount !== '0' && destinationAddress.length > 0, fromAmount, - fromToken: solanaNativeTokenAddress, - toToken: zeroAddress, + fromToken: fromToken.address, + toToken: toToken.address, fromChainId: sourceChainId, toChainId: destinationChainId, fromAddress: sourceWallet.account.address, toAddress: destinationAddress, - slippage: '0.003', + slippage: '0.005', }); const selectedRoute = useMemo(() => getPreferredRoute(routes), [routes]); - const routeHint = useMemo(() => { - if (routesError || isLoadingRoutes || selectedRoute) { - return null; - } - - if (!sourceWallet.isConnected) { - return 'Connect the source wallet to fetch a route.'; - } - - if (fromAmount === '0') { - return 'Enter an amount to fetch a route.'; - } - - if (!destinationAddress.length) { - return 'Enter a destination address to fetch a route.'; - } - - if (!isDestinationAddressValid) { - return 'Enter a valid Arbitrum One address.'; - } - - return 'No route available for the current inputs.'; - }, [ - destinationAddress.length, - fromAmount, - isDestinationAddressValid, - isLoadingRoutes, - routesError, - selectedRoute, - sourceWallet.isConnected, - ]); + const canRequestRoute = + sourceWallet.isConnected && fromAmount !== '0' && destinationAddress.length > 0; const executeTransfer = async () => { if (!selectedRoute) { @@ -274,23 +355,30 @@ function SolanaPocTransferPanelContent({ } setIsExecuting(true); + setExecutionError(undefined); try { const { transactionRequest } = await getStepTransaction(selectedRoute.protocolData.step); - const serializedTransaction = transactionRequest?.data; - if (!serializedTransaction) { - throw new Error('LI.FI did not return a Solana transaction payload.'); + if (sourceWallet.account.chain?.id !== sourceChainId) { + await switchChain(wagmiConfig, { chainId: sourceChainId }); } - const starter = new SolanaTransferStarter(); - const result = await starter.transfer({ - signer: sourceSigner, - serializedTransaction, - }); + const result = + sourceWallet.ecosystem === 'solana' + ? await sourceWallet.sendTransaction({ + ecosystem: 'solana', + serializedTransaction: getSolanaTransactionPayload(transactionRequest), + }) + : await sourceWallet.sendTransaction({ + ecosystem: 'evm', + txRequest: getEvmTransactionPayload(transactionRequest), + }); setTransactionHash(result.hash); await result.wait?.(); + } catch (error) { + setExecutionError(getErrorMessage(error)); } finally { setIsExecuting(false); } @@ -302,98 +390,71 @@ function SolanaPocTransferPanelContent({

Solana Wallet Interactions PoC

-

- Hardcoded flow: native SOL on Solana to native ETH on Arbitrum One. -

-
-
-

Solana Wallet

-

- {solanaWallet.account.walletInfo?.name ?? 'Unknown wallet'} -

-

{truncateAddress(solanaWallet.account.address)}

- -
- -
-

EVM Wallet

-

- {evmWallet.account.walletInfo?.name ?? 'Unknown wallet'} -

-

{truncateAddress(evmWallet.account.address)}

- -
+
+ {pocTransferPresets.map((preset) => { + const isSelected = preset.id === selectedPreset.id; + + return ( + + ); + })}
-
-

Source Balances

- {sourceBalanceError ? ( -

{sourceBalanceError.message}

- ) : ( - sourceBridgeTokens.map((token) => ( -
- {token.symbol} - {formatBalance(sourceBalances?.[token.address], token.decimals)} -
- )) - )} -
+ + +
-
-

Destination Balances

- {destinationBalanceError ? ( -

{destinationBalanceError.message}

- ) : ( - destinationBridgeTokens.map((token) => ( -
- {token.symbol} - {formatBalance(destinationBalances?.[token.address], token.decimals)} -
- )) - )} -
+
+ +
-

Transfer

+
+ {sourceConfig.label} {fromToken.symbol} {'->'} {destinationConfig.label} {toToken.symbol} +
@@ -423,17 +481,28 @@ function SolanaPocTransferPanelContent({ ) : null} {routesError ?

{routesError.message}

: null} - {routeHint ?

{routeHint}

: null} + {executionError ?

{executionError}

: null} + {!routesError && !isLoadingRoutes && !selectedRoute && canRequestRoute ? ( +

No route available for the current inputs.

+ ) : null} + {!routesError && !isLoadingRoutes && !selectedRoute && !canRequestRoute ? ( +

+ Connect wallets, enter an amount, and provide a destination address. +

+ ) : null} {selectedRoute ? (
Tool: {selectedRoute.protocolData.tool.name}
-
To amount: {utils.formatUnits(selectedRoute.toAmount.amount, 18)} ETH
+
+ To amount: {utils.formatUnits(selectedRoute.toAmount.amount, toToken.decimals)}{' '} + {toToken.symbol} +
) : null} {transactionHash ? ( -

Last transaction: {transactionHash}

+

Last transaction: {transactionHash}

) : null}