diff --git a/components/Nonprofit/NonprofitInfoPopover.tsx b/components/Nonprofit/NonprofitInfoPopover.tsx index a564f4ae1..6dc02593c 100644 --- a/components/Nonprofit/NonprofitInfoPopover.tsx +++ b/components/Nonprofit/NonprofitInfoPopover.tsx @@ -5,6 +5,7 @@ import { ExternalLink, X } from 'lucide-react'; import { useEffect, useState, useRef } from 'react'; import { Button } from '@/components/ui/Button'; import { CHAIN_IDS } from '@/constants/chains'; +import { maskAddress } from '@/utils/stringUtils'; interface NonprofitInfoPopoverProps { nonprofit: NonprofitOrg; @@ -100,7 +101,7 @@ export function NonprofitInfoPopover({ nonprofit, position, onClose }: Nonprofit className="text-primary-600 hover:underline flex items-center gap-1 break-all" onClick={(e) => e.stopPropagation()} > - {baseDeployment.address.slice(0, 8)}...{baseDeployment.address.slice(-6)} + {maskAddress(baseDeployment.address, 8, 6)} diff --git a/components/modals/ResearchCoin/DepositModal.tsx b/components/modals/ResearchCoin/DepositModal.tsx index 5df52d200..e22e0c5cd 100644 --- a/components/modals/ResearchCoin/DepositModal.tsx +++ b/components/modals/ResearchCoin/DepositModal.tsx @@ -1,9 +1,8 @@ 'use client'; -import { Dialog, Transition, DialogPanel, DialogTitle } from '@headlessui/react'; -import { Fragment, useCallback, useMemo, useState, useEffect, useRef } from 'react'; -import { X as XIcon, Check, AlertCircle } from 'lucide-react'; -import { formatRSC } from '@/utils/number'; +import { useCallback, useMemo, useState, useEffect, useRef } from 'react'; +import { Loader2 } from 'lucide-react'; +import { BaseModal } from '@/components/ui/BaseModal'; import { ResearchCoinIcon } from '@/components/ui/icons/ResearchCoinIcon'; import { useAccount } from 'wagmi'; import { useWalletRSCBalance } from '@/hooks/useWalletRSCBalance'; @@ -11,7 +10,13 @@ import { useIsMobile } from '@/hooks/useIsMobile'; import { Transaction, TransactionButton } from '@coinbase/onchainkit/transaction'; import { Interface } from 'ethers'; import { TransactionService } from '@/services/transaction.service'; -import { RSC, TRANSFER_ABI } from '@/constants/tokens'; +import { getRSCForNetwork, NetworkType, TRANSFER_ABI, NETWORK_CONFIG } from '@/constants/tokens'; +import { Alert } from '@/components/ui/Alert'; +import { DepositSuccessView } from './DepositSuccessView'; +import { NetworkSelectorSection } from './shared/NetworkSelectorSection'; +import { BalanceDisplay } from './shared/BalanceDisplay'; +import { TransactionFooter } from './shared/TransactionFooter'; +import toast from 'react-hot-toast'; const HOT_WALLET_ADDRESS_ENV = process.env.NEXT_PUBLIC_WEB3_WALLET_ADDRESS; if (!HOT_WALLET_ADDRESS_ENV || HOT_WALLET_ADDRESS_ENV.trim() === '') { @@ -19,13 +24,6 @@ if (!HOT_WALLET_ADDRESS_ENV || HOT_WALLET_ADDRESS_ENV.trim() === '') { } const HOT_WALLET_ADDRESS = HOT_WALLET_ADDRESS_ENV as `0x${string}`; -// Network configuration based on environment -const IS_PRODUCTION = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'; -const NETWORK_NAME = IS_PRODUCTION ? 'Base' : 'Base Sepolia'; -const NETWORK_DESCRIPTION = IS_PRODUCTION - ? 'Deposits are processed on Base L2' - : 'Deposits are processed on Base Sepolia testnet'; - // Define types for blockchain transaction call type Call = { to: `0x${string}`; @@ -50,44 +48,89 @@ type TransactionStatus = export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: DepositModalProps) { const [amount, setAmount] = useState(''); + const [selectedNetwork, setSelectedNetwork] = useState('BASE'); const [isInitiating, isDepositButtonDisabled] = useState(false); + const contentRef = useRef(null); const { address } = useAccount(); - const { balance: walletBalance } = useWalletRSCBalance(); + + const { balance: baseBalance, isLoading: isBaseBalanceLoading } = useWalletRSCBalance({ + network: 'BASE', + }); + const { balance: ethereumBalance, isLoading: isEthereumBalanceLoading } = useWalletRSCBalance({ + network: 'ETHEREUM', + }); + + const walletBalance = selectedNetwork === 'BASE' ? baseBalance : ethereumBalance; + const isWalletBalanceLoading = + selectedNetwork === 'BASE' ? isBaseBalanceLoading : isEthereumBalanceLoading; + const isMobile = useIsMobile(); const [txStatus, setTxStatus] = useState({ state: 'idle' }); const hasCalledSuccessRef = useRef(false); const hasProcessedDepositRef = useRef(false); const processedTxHashRef = useRef(null); + const hasSetDefaultRef = useRef(false); + + const rscToken = useMemo(() => getRSCForNetwork(selectedNetwork), [selectedNetwork]); + const networkConfig = NETWORK_CONFIG[selectedNetwork]; + const blockExplorerUrl = networkConfig.explorerUrl; - // Reset transaction status when modal opens useEffect(() => { if (isOpen) { setTxStatus({ state: 'idle' }); setAmount(''); + hasSetDefaultRef.current = false; isDepositButtonDisabled(false); hasCalledSuccessRef.current = false; hasProcessedDepositRef.current = false; processedTxHashRef.current = null; + } else { + // Delay reset to ensure modal closing animation completes + const timeoutId = setTimeout(() => { + setTxStatus({ state: 'idle' }); + setAmount(''); + hasSetDefaultRef.current = false; + isDepositButtonDisabled(false); + hasCalledSuccessRef.current = false; + hasProcessedDepositRef.current = false; + processedTxHashRef.current = null; + }, 300); + + return () => clearTimeout(timeoutId); } }, [isOpen]); - // Handle custom close with state reset + // Smart default selection based on wallet balances + useEffect(() => { + if (!isOpen || hasSetDefaultRef.current) return; + + if (!isBaseBalanceLoading && !isEthereumBalanceLoading) { + const baseHasBalance = baseBalance > 0; + const ethereumHasBalance = ethereumBalance > 0; + + if (ethereumHasBalance && !baseHasBalance) { + setSelectedNetwork('ETHEREUM'); + } else { + setSelectedNetwork('BASE'); + } + + hasSetDefaultRef.current = true; + } + }, [isOpen, baseBalance, ethereumBalance, isBaseBalanceLoading, isEthereumBalanceLoading]); + const handleClose = useCallback(() => { setTxStatus({ state: 'idle' }); setAmount(''); onClose(); }, [onClose]); - // Handle amount input change with validation const handleAmountChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; - // Only allow positive integers if (value === '' || /^\d+$/.test(value)) { setAmount(value); } }, []); - // Memoize derived values const depositAmount = useMemo(() => parseInt(amount || '0', 10), [amount]); const calculateNewBalance = useCallback( @@ -106,7 +149,6 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep [address, amount, depositAmount, walletBalance, isInitiating, isMobile] ); - // Function to check if inputs should be disabled const isInputDisabled = useCallback(() => { return ( !address || @@ -166,7 +208,7 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep amount: depositAmount, transaction_hash: txHash, from_address: address!, - network: 'BASE', + network: selectedNetwork, }).catch((error) => { console.error('Failed to record deposit:', error); }); @@ -183,14 +225,16 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep if (status.statusName === 'error') { console.error('Transaction error full status:', JSON.stringify(status, null, 2)); + const errorMessage = status.statusData?.message || 'Transaction failed'; setTxStatus({ state: 'error', - message: status.statusData?.message || 'Transaction failed', + message: errorMessage, }); + toast.error(errorMessage); isDepositButtonDisabled(false); } }, - [depositAmount, address, onSuccess] + [depositAmount, address, onSuccess, selectedNetwork] ); const callsCallback = useCallback(async () => { @@ -211,12 +255,58 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep // Cast the result to Call type with proper hex type const transferCall: Call = { - to: RSC.address as `0x${string}`, + to: rscToken.address as `0x${string}`, data: encodedData as `0x${string}`, }; return [transferCall]; - }, [amount, depositAmount, walletBalance]); + }, [amount, depositAmount, walletBalance, rscToken.address]); + + const footer = useMemo(() => { + const txHash = txStatus.state === 'success' ? txStatus.txHash : undefined; + + if (txHash) { + return ; + } + + const isSponsored = selectedNetwork === 'BASE'; + + return ( + + +
+ + + {txStatus.state === 'pending' ? 'Processing...' : 'Building transaction...'} + + ), + }} + /> +
+
+
+ ); + }, [ + rscToken.chainId, + callsCallback, + handleOnStatus, + setButtonDisabledOnClick, + isButtonDisabled, + txStatus, + blockExplorerUrl, + selectedNetwork, + ]); // If no wallet is connected, show nothing - assuming modal shouldn't open in this state if (!address) { @@ -224,207 +314,124 @@ export function DepositModal({ isOpen, onClose, currentBalance, onSuccess }: Dep } return ( - <> - - - -
- - -
-
- - -
- - Deposit RSC - - -
- -
- {isMobile && ( -
-
- -

- Deposits are temporarily unavailable on mobile devices. Please use a - desktop browser to make deposits. -

-
-
- )} - - {/* Network Info */} -
-
- {`${NETWORK_NAME} -
- {NETWORK_NAME} - {NETWORK_DESCRIPTION} -
-
-
- - {/* Wallet RSC Balance */} -
-
- Wallet Balance: -
- - - {walletBalance.toFixed(2)} - - RSC -
-
-
- - {/* Amount Input */} -
-
- Amount to Deposit - -
-
- -
- RSC -
-
- {depositAmount > walletBalance && ( -

- Deposit amount exceeds your wallet balance. -

- )} -
- - {/* Balance Display */} -
-
- Current Balance: -
-
- - - {formatRSC({ amount: currentBalance })} - - RSC -
-
-
- -
-
- After Deposit: -
-
- - 0 && depositAmount <= walletBalance ? 'text-green-600' : 'text-gray-900'}`} - > - {depositAmount > 0 && depositAmount <= walletBalance - ? formatRSC({ amount: calculateNewBalance() }) - : formatRSC({ amount: currentBalance })} - - RSC -
-
-
-
- - {/* Transaction Button */} - -
- -
-
- - {/* Transaction Status Display */} - {(txStatus.state === 'success' || txStatus.state === 'error') && ( -
- {txStatus.state === 'success' && ( -
-
- - Deposit successful! -
-

- It can take up to 10-20 minutes for the deposit to appear in your - account. -

-
- )} - - {txStatus.state === 'error' && ( -
-
- - Deposit failed -
-

{txStatus.message}

-
- )} -
- )} -
- - + +
+ {txStatus.state === 'success' ? ( + + ) : ( + <> + {txStatus.state === 'error' && ( + +
+
Deposit failed
+ {'message' in txStatus && txStatus.message && ( +
{txStatus.message}
+ )} +
+
+ )} + + {isMobile && ( + + Deposits are temporarily unavailable on mobile devices. Please use a desktop browser + to make deposits. + + )} + + {/* Network Selector */} + + + {/* Wallet RSC Balance */} +
+
+ Wallet Balance: +
+ + {isWalletBalanceLoading ? ( + Loading... + ) : ( + <> + + {walletBalance.toFixed(2)} + + RSC + + )} +
+
-
-
-
- + + {/* Amount Input */} +
+
+ Amount to Deposit + +
+
+ +
+ RSC +
+
+ {depositAmount > walletBalance && ( +

+ Deposit amount exceeds your wallet balance. +

+ )} +
+ + {/* Balance Display */} + 0 && depositAmount <= walletBalance + ? calculateNewBalance() + : currentBalance + } + futureBalanceLabel="After Deposit" + futureBalanceColor={ + depositAmount > 0 && depositAmount <= walletBalance ? 'green' : 'gray' + } + /> + + )} + + ); } diff --git a/components/modals/ResearchCoin/DepositSuccessView.tsx b/components/modals/ResearchCoin/DepositSuccessView.tsx new file mode 100644 index 000000000..1309e9db8 --- /dev/null +++ b/components/modals/ResearchCoin/DepositSuccessView.tsx @@ -0,0 +1,29 @@ +import { NetworkConfig } from '@/constants/tokens'; +import { TransactionSuccessView } from './shared/TransactionSuccessView'; + +interface DepositSuccessViewProps { + depositAmount: number; + networkConfig: NetworkConfig; + address: string; +} + +export function DepositSuccessView({ + depositAmount, + networkConfig, + address, +}: DepositSuccessViewProps) { + return ( + + ); +} diff --git a/components/modals/ResearchCoin/WithdrawModal.tsx b/components/modals/ResearchCoin/WithdrawModal.tsx index 715dea414..5d25596fa 100644 --- a/components/modals/ResearchCoin/WithdrawModal.tsx +++ b/components/modals/ResearchCoin/WithdrawModal.tsx @@ -1,21 +1,26 @@ 'use client'; -import { Dialog, Transition, DialogPanel, DialogTitle } from '@headlessui/react'; -import { Fragment, useCallback, useMemo, useState, useEffect } from 'react'; -import { X as XIcon, Check, AlertCircle, ExternalLink, Loader2 } from 'lucide-react'; +import { useCallback, useMemo, useState, useEffect, useRef } from 'react'; +import { Check, AlertCircle, Loader2, Copy } from 'lucide-react'; +import { BaseModal } from '@/components/ui/BaseModal'; import { formatRSC } from '@/utils/number'; import { ResearchCoinIcon } from '@/components/ui/icons/ResearchCoinIcon'; import { useAccount } from 'wagmi'; import { useWithdrawRSC } from '@/hooks/useWithdrawRSC'; import { cn } from '@/utils/styles'; - -// Network configuration based on environment -const IS_PRODUCTION = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'; -const NETWORK_NAME = IS_PRODUCTION ? 'Base' : 'Base Sepolia'; -const NETWORK_DESCRIPTION = IS_PRODUCTION - ? 'Withdrawals are processed on Base L2' - : 'Withdrawals are processed on Base Sepolia testnet'; -const BLOCK_EXPLORER_URL = IS_PRODUCTION ? 'https://basescan.org' : 'https://sepolia.basescan.org'; +import { NETWORK_CONFIG, NetworkType } from '@/constants/tokens'; +import { NetworkSelectorSection } from './shared/NetworkSelectorSection'; +import { BalanceDisplay } from './shared/BalanceDisplay'; +import { TransactionFooter } from './shared/TransactionFooter'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { Input } from '@/components/ui/form/Input'; +import { Checkbox } from '@/components/ui/form/Checkbox'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import toast from 'react-hot-toast'; +import { WithdrawalSuccessView } from './WithdrawalSuccessView'; +import { isValidEthereumAddress } from '@/utils/stringUtils'; +import { useCopyAddress } from '@/hooks/useCopyAddress'; // Minimum withdrawal amount in RSC const MIN_WITHDRAWAL_AMOUNT = 150; @@ -34,35 +39,70 @@ export function WithdrawModal({ onSuccess, }: WithdrawModalProps) { const [amount, setAmount] = useState(''); + const [selectedNetwork, setSelectedNetwork] = useState('BASE'); + const [addressMode, setAddressMode] = useState<'connected' | 'custom'>('connected'); + const [customAddress, setCustomAddress] = useState(''); + const contentRef = useRef(null); const { address } = useAccount(); - const [{ txStatus, isLoading, fee, isFeeLoading, feeError }, withdrawRSC] = useWithdrawRSC(); + const [{ txStatus, isLoading, fee, isFeeLoading, feeError }, withdrawRSC, resetTransaction] = + useWithdrawRSC({ + network: selectedNetwork, + }); + + const networkConfig = NETWORK_CONFIG[selectedNetwork]; + const blockExplorerUrl = networkConfig.explorerUrl; - // Reset state when modal is closed useEffect(() => { if (!isOpen) { - setAmount(''); + // Delay reset to ensure modal closing animation completes + const timeoutId = setTimeout(() => { + setAmount(''); + setSelectedNetwork('BASE'); + setAddressMode('connected'); + setCustomAddress(''); + resetTransaction(); + }, 300); + + return () => clearTimeout(timeoutId); } - }, [isOpen]); + }, [isOpen, resetTransaction]); + + const withdrawalAddress = useMemo(() => { + return addressMode === 'connected' ? address : customAddress; + }, [addressMode, address, customAddress]); + + const isCustomAddressValid = useMemo(() => { + if (addressMode === 'connected') return true; + return isValidEthereumAddress(customAddress); + }, [addressMode, customAddress]); + + useEffect(() => { + if (txStatus.state === 'error') { + const errorMessage = 'message' in txStatus ? txStatus.message : 'Transaction failed'; + toast.error(errorMessage); + } + }, [txStatus]); + + useEffect(() => { + if (feeError) { + toast.error(`Unable to fetch fee: ${feeError}`); + } + }, [feeError]); - // Handle amount input change with validation const handleAmountChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; - // Only allow positive integers if (value === '' || /^\d+$/.test(value)) { setAmount(value); } }, []); - // Memoize derived values const withdrawAmount = useMemo(() => parseInt(amount || '0', 10), [amount]); - // Calculate the amount the user will actually receive const amountUserWillReceive = useMemo((): number => { if (!fee) return 0; return Math.max(0, withdrawAmount - fee); }, [withdrawAmount, fee]); - // Check if the withdrawal amount is below minimum const isBelowMinimum = useMemo( () => withdrawAmount > 0 && withdrawAmount < MIN_WITHDRAWAL_AMOUNT, [withdrawAmount] @@ -93,7 +133,9 @@ export function WithdrawModal({ !fee || hasInsufficientBalance || isBelowMinimum || - amountUserWillReceive <= 0, + amountUserWillReceive <= 0 || + !isCustomAddressValid || + !withdrawalAddress, [ amount, withdrawAmount, @@ -103,10 +145,11 @@ export function WithdrawModal({ hasInsufficientBalance, isBelowMinimum, amountUserWillReceive, + isCustomAddressValid, + withdrawalAddress, ] ); - // Function to check if inputs should be disabled const isInputDisabled = useCallback(() => { return !address || txStatus.state === 'pending' || txStatus.state === 'success'; }, [address, txStatus.state]); @@ -118,283 +161,304 @@ export function WithdrawModal({ }, [availableBalance, isInputDisabled, fee]); const handleWithdraw = useCallback(async () => { - if (!address || !amount || isButtonDisabled || !fee) { + if (!withdrawalAddress || !amount || isButtonDisabled || !fee) { return; } const result = await withdrawRSC({ - to_address: address, + to_address: withdrawalAddress, agreed_to_terms: true, amount: amount, - network: 'BASE', + network: selectedNetwork, }); - // Call onSuccess callback when withdrawal is successful if (result && txStatus.state === 'success' && onSuccess) { onSuccess(); } - }, [address, amount, isButtonDisabled, withdrawRSC, txStatus.state, onSuccess, fee]); + }, [ + withdrawalAddress, + amount, + isButtonDisabled, + withdrawRSC, + txStatus.state, + onSuccess, + fee, + selectedNetwork, + ]); + + const { isCopied: isAddressCopied, copyAddress } = useCopyAddress(); + + const handleCopyAddress = useCallback(() => { + copyAddress(withdrawalAddress); + }, [withdrawalAddress, copyAddress]); + + const handleCustomAddressChange = useCallback((e: React.ChangeEvent) => { + setCustomAddress(e.target.value); + }, []); + + const footer = useMemo(() => { + const txHash = txStatus.state === 'success' ? txStatus.txHash : undefined; + + if (txHash) { + return ; + } + + return ( + + + + ); + }, [txStatus, blockExplorerUrl, isButtonDisabled, handleWithdraw, isFeeLoading]); - // If no wallet is connected, show nothing if (!address) { return null; } return ( - - - -
- - -
-
- - -
- - Withdraw RSC - - + +
+ {txStatus.state === 'success' ? ( + + ) : ( + /* Form View */ + <> + {txStatus.state === 'error' && ( + +
+
Withdrawal failed
+ {'message' in txStatus && txStatus.message && ( +
{txStatus.message}
+ )}
- -
- {/* Network Info */} -
-
- {`${NETWORK_NAME} -
- {NETWORK_NAME} - {NETWORK_DESCRIPTION} -
-
-
- - {/* Amount Input */} -
-
- Amount to Withdraw - -
-
- -
- RSC -
-
- {isBelowMinimum && ( -

- Minimum withdrawal amount is {MIN_WITHDRAWAL_AMOUNT} RSC. -

- )} - {hasInsufficientBalance && ( -

- Withdrawal amount exceeds your available balance. -

- )} -
- - {/* Fee Display */} -
- {isFeeLoading ? ( -

- - Calculating network fee... -

- ) : feeError ? ( -

- - Unable to fetch fee: {feeError} -

- ) : ( -
-

- A network fee of{' '} - {fee} RSC will be deducted from your withdrawal amount. -

- - {/* Added "You will receive" row */} - {withdrawAmount > 0 && fee && ( -
-
- - You will receive: - -
- - - {formatRSC({ amount: amountUserWillReceive })} - - RSC -
-
- {amountUserWillReceive <= 0 && withdrawAmount > 0 && ( -

- Withdrawal amount must be greater than the network fee. -

- )} -
- )} -
- )} -
- - {/* Withdrawal Address Display */} -
-
- Withdrawal Address -
-
- {address} + + )} + + {/* Network Selector */} + + + {/* Amount Input */} +
+
+ Amount to Withdraw + +
+
+ +
+ RSC +
+
+ {isBelowMinimum && ( +

+ Minimum withdrawal amount is {MIN_WITHDRAWAL_AMOUNT} RSC. +

+ )} + {hasInsufficientBalance && ( +

+ Withdrawal amount exceeds your available balance. +

+ )} +
+ + {/* Fee Display */} +
+ {feeError ? ( +

+ + Unable to fetch fee: {feeError} +

+ ) : ( +
+
+
+ {isFeeLoading ? ( + + ) : ( + + )}
+

+ A network fee of{' '} + {isFeeLoading ? ( + + ) : ( + {fee} + )}{' '} + RSC will be deducted from your withdrawal amount. +

- {/* Balance Display */} -
-
- Current Balance: -
-
+ {withdrawAmount > 0 && (fee || isFeeLoading) && ( +
+
+ You will receive: +
- - {formatRSC({ amount: availableBalance })} - - RSC -
-
-
- -
-
- After Withdrawal: -
-
- - 0 ? 'text-red-600' : 'text-gray-900' - )} - > - {withdrawAmount > 0 - ? formatRSC({ amount: calculateNewBalance() }) - : formatRSC({ amount: availableBalance })} - + {isFeeLoading ? ( + + ) : ( + + {formatRSC({ amount: amountUserWillReceive })} + + )} RSC
+ {!isFeeLoading && amountUserWillReceive <= 0 && withdrawAmount > 0 && fee && ( +

+ Withdrawal amount must be greater than the network fee. +

+ )}
-
- - {/* Action Button */} - {txStatus.state === 'success' ? ( - - View Transaction - - - ) : ( + )} +
+ )} +
+ + {/* Withdrawal Address */} +
+ Withdrawal Address + + {/* Address Mode Toggle */} + { + if (!isInputDisabled()) { + setAddressMode(checked ? 'connected' : 'custom'); + } + }} + disabled={isInputDisabled()} + /> + + {/* Address Input */} + {addressMode === 'connected' ? ( + - {isFeeLoading - ? 'Loading fee...' - : txStatus.state === 'pending' - ? 'Processing...' - : 'Withdraw RSC'} + {isAddressCopied ? ( + + ) : ( + + )} + } + /> + ) : ( +
+ + {isAddressCopied ? ( + + ) : ( + + )} + + ) + } + /> + {customAddress && !isCustomAddressValid && ( +

+ Please enter a valid Ethereum address (0x followed by 40 hex characters). +

)} +
+ )} - {/* Transaction Status Display */} - {txStatus.state !== 'idle' && ( -
- {txStatus.state === 'pending' && ( -
- - Withdrawal in progress... -
- )} - - {txStatus.state === 'success' && ( -
- - Withdrawal successful! -
- )} - - {txStatus.state === 'error' && ( -
-
- - Withdrawal failed -
-

{txStatus.message}

-
- )} -
- )} + {/* Network Compatibility Warning */} + +
+ Ensure the destination wallet supports {NETWORK_CONFIG[selectedNetwork].name}
- - -
-
-
-
+ + + + {/* Balance Display */} + 0 ? calculateNewBalance() : availableBalance} + futureBalanceLabel="After Withdrawal" + futureBalanceColor={withdrawAmount > 0 ? 'red' : 'gray'} + /> + + )} + + ); } diff --git a/components/modals/ResearchCoin/WithdrawalSuccessView.tsx b/components/modals/ResearchCoin/WithdrawalSuccessView.tsx new file mode 100644 index 000000000..63a6e876e --- /dev/null +++ b/components/modals/ResearchCoin/WithdrawalSuccessView.tsx @@ -0,0 +1,33 @@ +import { NetworkConfig } from '@/constants/tokens'; +import { TransactionSuccessView } from './shared/TransactionSuccessView'; + +interface WithdrawalSuccessViewProps { + withdrawAmount: number; + fee: number; + amountReceived: number; + networkConfig: NetworkConfig; + address: string; +} + +export function WithdrawalSuccessView({ + withdrawAmount, + fee, + amountReceived, + networkConfig, + address, +}: WithdrawalSuccessViewProps) { + return ( + + ); +} diff --git a/components/modals/ResearchCoin/shared/BalanceDisplay.tsx b/components/modals/ResearchCoin/shared/BalanceDisplay.tsx new file mode 100644 index 000000000..72c558d84 --- /dev/null +++ b/components/modals/ResearchCoin/shared/BalanceDisplay.tsx @@ -0,0 +1,61 @@ +import { ResearchCoinIcon } from '@/components/ui/icons/ResearchCoinIcon'; +import { formatRSC } from '@/utils/number'; +import { cn } from '@/utils/styles'; + +interface BalanceDisplayProps { + currentBalance: number; + futureBalance: number; + futureBalanceLabel: string; + futureBalanceColor?: 'green' | 'red' | 'gray'; + showFutureBalance?: boolean; +} + +export function BalanceDisplay({ + currentBalance, + futureBalance, + futureBalanceLabel, + futureBalanceColor = 'gray', + showFutureBalance = true, +}: BalanceDisplayProps) { + return ( +
+
+ Current Balance: +
+
+ + + {formatRSC({ amount: currentBalance })} + + RSC +
+
+
+ + {showFutureBalance && ( + <> +
+
+ {futureBalanceLabel}: +
+
+ + + {formatRSC({ amount: futureBalance })} + + RSC +
+
+
+ + )} +
+ ); +} diff --git a/components/modals/ResearchCoin/shared/NetworkSelectorSection.tsx b/components/modals/ResearchCoin/shared/NetworkSelectorSection.tsx new file mode 100644 index 000000000..df5a1d255 --- /dev/null +++ b/components/modals/ResearchCoin/shared/NetworkSelectorSection.tsx @@ -0,0 +1,50 @@ +import Image from 'next/image'; +import { NetworkSelector } from '@/components/ui/NetworkSelector'; +import { NETWORK_CONFIG, NetworkType } from '@/constants/tokens'; + +interface NetworkSelectorSectionProps { + selectedNetwork: NetworkType; + onNetworkChange: (network: NetworkType) => void; + disabled?: boolean; + showDescription?: boolean; + customBadges?: Partial>; +} + +export function NetworkSelectorSection({ + selectedNetwork, + onNetworkChange, + disabled = false, + showDescription = true, + customBadges, +}: NetworkSelectorSectionProps) { + return ( +
+
+ Network +
+ {(Object.keys(NETWORK_CONFIG) as NetworkType[]).map((network) => { + const config = NETWORK_CONFIG[network]; + return ( + {`${config.name} + ); + })} +
+
+ +
+ ); +} diff --git a/components/modals/ResearchCoin/shared/TransactionFooter.tsx b/components/modals/ResearchCoin/shared/TransactionFooter.tsx new file mode 100644 index 000000000..69efa6ba1 --- /dev/null +++ b/components/modals/ResearchCoin/shared/TransactionFooter.tsx @@ -0,0 +1,29 @@ +import { ExternalLink } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; + +interface TransactionFooterProps { + txHash?: string; + blockExplorerUrl: string; + children?: React.ReactNode; +} + +export function TransactionFooter({ txHash, blockExplorerUrl, children }: TransactionFooterProps) { + if (txHash) { + const normalizedTxHash = txHash.startsWith('0x') ? txHash : `0x${txHash}`; + return ( + + + + ); + } + + return <>{children}; +} diff --git a/components/modals/ResearchCoin/shared/TransactionSuccessView.tsx b/components/modals/ResearchCoin/shared/TransactionSuccessView.tsx new file mode 100644 index 000000000..b16a55e82 --- /dev/null +++ b/components/modals/ResearchCoin/shared/TransactionSuccessView.tsx @@ -0,0 +1,137 @@ +import Image from 'next/image'; +import { CheckCircle2, Check, Copy, Clock } from 'lucide-react'; +import { formatRSC } from '@/utils/number'; +import { ResearchCoinIcon } from '@/components/ui/icons/ResearchCoinIcon'; +import { NetworkConfig } from '@/constants/tokens'; +import { maskAddress } from '@/utils/stringUtils'; +import { useCopyAddress } from '@/hooks/useCopyAddress'; + +interface TransactionSuccessViewProps { + title: string; + subtitle: string; + amount: number; + networkConfig: NetworkConfig; + address: string; + addressLabel: 'From Address' | 'To Address'; + amountLabel: string; + amountColor?: 'green' | 'gray'; + fee?: number; + amountReceived?: number; + showProcessingTime?: boolean; + processingTimeMessage?: string; +} + +export function TransactionSuccessView({ + title, + subtitle, + amount, + networkConfig, + address, + addressLabel, + amountLabel, + amountColor = 'green', + fee, + amountReceived, + showProcessingTime = false, + processingTimeMessage, +}: TransactionSuccessViewProps) { + const { isCopied, copyAddress } = useCopyAddress(); + const maskedAddress = maskAddress(address); + + return ( +
+
+
+ +
+

{title}

+

{subtitle}

+
+ +
+
+ {amountLabel} +
+ + + {formatRSC({ amount })} + + RSC +
+
+ + {fee !== undefined && ( + <> +
+ Network Fee +
+ -{fee} + RSC +
+
+ + {amountReceived !== undefined && ( +
+
+ You Received +
+ + + {formatRSC({ amount: amountReceived })} + + RSC +
+
+
+ )} + + )} +
+ +
+
+ Network +
+ {networkConfig.name} + {networkConfig.name} +
+
+ +
+ {addressLabel} +
+ {maskedAddress} + +
+
+
+ + {showProcessingTime && processingTimeMessage && ( +
+
+ +
+

Processing Time

+

{processingTimeMessage}

+
+
+
+ )} +
+ ); +} diff --git a/components/modals/WalletModal.tsx b/components/modals/WalletModal.tsx index 69ce58c10..1a8c517be 100644 --- a/components/modals/WalletModal.tsx +++ b/components/modals/WalletModal.tsx @@ -7,6 +7,7 @@ import { X as XIcon } from 'lucide-react'; import Image from 'next/image'; import { coinbaseWallet, metaMask } from 'wagmi/connectors'; import toast from 'react-hot-toast'; +import { maskAddress } from '@/utils/stringUtils'; interface WalletModalProps { isOpen: boolean; @@ -14,10 +15,6 @@ interface WalletModalProps { onError?: (error: Error) => void; } -function truncateWalletAddress(address: string): string { - return `${address.slice(0, 4)}...${address.slice(-4)}`; -} - export function WalletModal({ isOpen, onClose, onError }: WalletModalProps) { const { connectAsync, error } = useConnect(); @@ -29,7 +26,7 @@ export function WalletModal({ isOpen, onClose, onError }: WalletModalProps) { }); const data = await connectAsync({ connector: cbConnector }); if (data?.accounts && data.accounts.length > 0) { - toast.success(`Wallet Connected (${truncateWalletAddress(data.accounts[0])})`); + toast.success(`Wallet Connected (${maskAddress(data.accounts[0], 4, 4)})`); onClose(); } else { throw new Error('No accounts returned'); @@ -51,7 +48,7 @@ export function WalletModal({ isOpen, onClose, onError }: WalletModalProps) { }); const data = await connectAsync({ connector: mmConnector }); if (data?.accounts && data.accounts.length > 0) { - toast.success(`Wallet Connected (${truncateWalletAddress(data.accounts[0])})`); + toast.success(`Wallet Connected (${maskAddress(data.accounts[0], 4, 4)})`); onClose(); } else { throw new Error('No accounts returned'); diff --git a/components/ui/NetworkSelector.tsx b/components/ui/NetworkSelector.tsx new file mode 100644 index 000000000..bbf2e9e2e --- /dev/null +++ b/components/ui/NetworkSelector.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useMemo } from 'react'; +import Image from 'next/image'; +import { Check, ChevronDown } from 'lucide-react'; +import { BaseMenu, BaseMenuItem } from './form/BaseMenu'; +import { NETWORK_CONFIG, NetworkType } from '@/constants/tokens'; +import { cn } from '@/utils/styles'; + +interface NetworkSelectorProps { + value: NetworkType; + onChange: (network: NetworkType) => void; + disabled?: boolean; + className?: string; + showBadges?: boolean; + customBadges?: Partial>; + showDescription?: boolean; +} + +export function NetworkSelector({ + value, + onChange, + disabled = false, + className, + showBadges = true, + customBadges, + showDescription = true, +}: NetworkSelectorProps) { + const selectedNetwork = NETWORK_CONFIG[value]; + + const getBadge = (network: NetworkType): string | undefined => { + if (customBadges?.[network]) { + return customBadges[network]; + } + return NETWORK_CONFIG[network].badge; + }; + + const trigger = useMemo( + () => ( +
+ {`${selectedNetwork.name} +
+
+ {selectedNetwork.name} + {showBadges && getBadge(value) && ( + + {getBadge(value)} + + )} +
+ {showDescription && showBadges && selectedNetwork.description && ( + {selectedNetwork.description} + )} +
+ +
+ ), + [selectedNetwork, value, disabled, className, showBadges, showDescription, customBadges] + ); + + return ( + + {(Object.keys(NETWORK_CONFIG) as NetworkType[]).map((network) => { + const config = NETWORK_CONFIG[network]; + const isSelected = network === value; + + return ( + { + if (!disabled) { + onChange(network); + } + }} + className={cn( + 'flex items-center gap-3 px-3 py-3 rounded-md', + isSelected && 'bg-primary-50' + )} + > + {`${config.name} +
+
+ {config.name} + {showBadges && getBadge(network) && ( + + {getBadge(network)} + + )} +
+ {showDescription && showBadges && config.description && ( + + {config.description} + + )} +
+ {isSelected && } +
+ ); + })} +
+ ); +} diff --git a/constants/tokens.ts b/constants/tokens.ts index 9e257b157..3d009b382 100644 --- a/constants/tokens.ts +++ b/constants/tokens.ts @@ -1,9 +1,45 @@ import { Token } from '@coinbase/onchainkit/token'; const IS_PRODUCTION = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'; -const CHAIN_ID = IS_PRODUCTION - ? 8453 // Base mainnet - : 84532; // Base Sepolia testnet +export const BASE_CHAIN_ID = IS_PRODUCTION ? 8453 : 84532; // Base mainnet : Base Sepolia testnet +export const ETHEREUM_CHAIN_ID = IS_PRODUCTION ? 1 : 11155111; // Ethereum mainnet : Sepolia testnet + +export type NetworkType = 'BASE' | 'ETHEREUM'; + +export interface NetworkConfig { + chainId: number; + name: string; + rscAddress: string; + explorerUrl: string; + badge: string; + description: string; + icon: string; +} + +export const NETWORK_CONFIG: Record = { + BASE: { + chainId: BASE_CHAIN_ID, + name: IS_PRODUCTION ? 'Base' : 'Base Sepolia', + rscAddress: IS_PRODUCTION + ? '0xFbB75A59193A3525a8825BeBe7D4b56899E2f7e1' + : '0xdAf43508D785939D6C2d97c2df73d65c9359dBEa', + explorerUrl: IS_PRODUCTION ? 'https://basescan.org' : 'https://sepolia.basescan.org', + badge: 'Lower Fees', + description: 'Recommended network for low fees', + icon: '/base-logo.svg', + }, + ETHEREUM: { + chainId: ETHEREUM_CHAIN_ID, + name: IS_PRODUCTION ? 'Ethereum' : 'Sepolia', + rscAddress: IS_PRODUCTION + ? '0xd101dcc414f310268c37eeb4cd376ccfa507f571' + : '0xEe8D932a66aDA39E4EF08046734F601D04B6a3DA', + explorerUrl: IS_PRODUCTION ? 'https://etherscan.io' : 'https://sepolia.etherscan.io', + badge: 'Higher Fees', + description: 'Higher fees, with increased decentralization', + icon: '/ethereum-logo.svg', + }, +}; export const RSC: Token = { name: 'ResearchCoin', @@ -13,16 +49,31 @@ export const RSC: Token = { ? '0xFbB75A59193A3525a8825BeBe7D4b56899E2f7e1' : '0xdAf43508D785939D6C2d97c2df73d65c9359dBEa', image: 'RSC.webp', - chainId: CHAIN_ID, + chainId: BASE_CHAIN_ID, }; +/** + * Get RSC token configuration for a specific network + */ +export function getRSCForNetwork(network: NetworkType): Token { + const config = NETWORK_CONFIG[network]; + return { + name: 'ResearchCoin', + symbol: 'RSC', + decimals: 18, + address: config.rscAddress as `0x${string}`, + image: 'RSC.webp', + chainId: config.chainId, + }; +} + export const ETH: Token = { name: 'ETH', address: '', symbol: 'ETH', decimals: 18, image: 'ETH.webp', - chainId: CHAIN_ID, + chainId: BASE_CHAIN_ID, }; export const USDC: Token = { @@ -33,7 +84,7 @@ export const USDC: Token = { symbol: 'USDC', decimals: 6, image: 'USDC.webp', - chainId: CHAIN_ID, + chainId: BASE_CHAIN_ID, }; export const TRANSFER_ABI = [ diff --git a/contexts/OnchainContext.tsx b/contexts/OnchainContext.tsx index dc7031af8..8fa56d746 100644 --- a/contexts/OnchainContext.tsx +++ b/contexts/OnchainContext.tsx @@ -2,7 +2,61 @@ import type { ReactNode } from 'react'; import { OnchainKitProvider } from '@coinbase/onchainkit'; -import { base } from 'wagmi/chains'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { WagmiProvider, createConfig, http } from 'wagmi'; +import { base, baseSepolia, mainnet, sepolia } from 'wagmi/chains'; +import { coinbaseWallet, metaMask } from 'wagmi/connectors'; + +const IS_PRODUCTION = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'; + +const PAYMASTER_URL = process.env.NEXT_PUBLIC_PAYMASTER_URL?.trim() || undefined; +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://researchhub.com'; + +const productionConfig = createConfig({ + chains: [base, mainnet], + connectors: [ + coinbaseWallet({ + appName: 'ResearchHub', + preference: 'all', + }), + metaMask({ + dappMetadata: { + name: 'ResearchHub', + url: SITE_URL, + }, + }), + ], + transports: { + [base.id]: http(), + [mainnet.id]: http(), + }, + ssr: true, +}); + +const developmentConfig = createConfig({ + chains: [baseSepolia, sepolia], + connectors: [ + coinbaseWallet({ + appName: 'ResearchHub', + preference: 'all', + }), + metaMask({ + dappMetadata: { + name: 'ResearchHub', + url: SITE_URL, + }, + }), + ], + transports: { + [baseSepolia.id]: http(), + [sepolia.id]: http(), + }, + ssr: true, +}); + +const wagmiConfig = IS_PRODUCTION ? productionConfig : developmentConfig; + +const queryClient = new QueryClient(); interface OnchainProviderProps { children: ReactNode; @@ -10,27 +64,34 @@ interface OnchainProviderProps { /** * Provider component for blockchain interactions using Coinbase's OnchainKit - * Configures wallet connections and blockchain interactions + * Configures wallet connections and blockchain interactions for Base and Ethereum networks */ export function OnchainProvider({ children }: OnchainProviderProps) { + const primaryChain = IS_PRODUCTION ? base : baseSepolia; + return ( - - {children} - + + + + {children} + + + ); } diff --git a/hooks/useCopyAddress.ts b/hooks/useCopyAddress.ts new file mode 100644 index 000000000..2bb003f83 --- /dev/null +++ b/hooks/useCopyAddress.ts @@ -0,0 +1,27 @@ +import { useState, useCallback } from 'react'; +import toast from 'react-hot-toast'; + +/** + * Hook for copying an address to clipboard with toast notifications + */ +export function useCopyAddress() { + const [isCopied, setIsCopied] = useState(false); + + const copyAddress = useCallback((address: string | null | undefined) => { + if (!address) return; + + navigator.clipboard.writeText(address).then( + () => { + setIsCopied(true); + toast.success('Address copied to clipboard!'); + setTimeout(() => setIsCopied(false), 2000); + }, + (err) => { + console.error('Failed to copy address: ', err); + toast.error('Failed to copy address.'); + } + ); + }, []); + + return { isCopied, copyAddress }; +} diff --git a/hooks/useWalletRSCBalance.ts b/hooks/useWalletRSCBalance.ts index 05c92b949..eb34e5e0d 100644 --- a/hooks/useWalletRSCBalance.ts +++ b/hooks/useWalletRSCBalance.ts @@ -2,7 +2,7 @@ import { useAccount, useBalance } from 'wagmi'; import { useMemo } from 'react'; -import { RSC } from '@/constants/tokens'; +import { getRSCForNetwork, NetworkType } from '@/constants/tokens'; interface UseWalletRSCBalanceReturn { balance: number; @@ -11,21 +11,31 @@ interface UseWalletRSCBalanceReturn { error: Error | null; } +interface UseWalletRSCBalanceOptions { + network?: NetworkType; +} + /** * Hook to check the connected wallet's RSC balance + * @param options - Options including network selection * @returns An object containing the wallet's RSC balance and related states */ -export function useWalletRSCBalance(): UseWalletRSCBalanceReturn { +export function useWalletRSCBalance( + options: UseWalletRSCBalanceOptions = {} +): UseWalletRSCBalanceReturn { + const { network = 'BASE' } = options; const { address } = useAccount(); + const rscToken = useMemo(() => getRSCForNetwork(network), [network]); + const { data: balanceData, isLoading, error, } = useBalance({ address, - token: RSC.address && RSC.address.startsWith('0x') ? (RSC.address as `0x${string}`) : undefined, - chainId: RSC.chainId, + token: rscToken.address?.startsWith('0x') ? (rscToken.address as `0x${string}`) : undefined, + chainId: rscToken.chainId, }); const balance = useMemo( diff --git a/hooks/useWithdrawRSC.ts b/hooks/useWithdrawRSC.ts index f2123e89d..6faa9da5c 100644 --- a/hooks/useWithdrawRSC.ts +++ b/hooks/useWithdrawRSC.ts @@ -6,6 +6,7 @@ import { WithdrawalRequest, WithdrawalResponse, } from '@/services/transaction.service'; +import { NetworkType } from '@/constants/tokens'; // Define transaction status type export type TransactionStatus = @@ -25,27 +26,34 @@ interface UseWithdrawRSCState { type WithdrawRSCFn = ( withdrawalData: Omit ) => Promise; -type UseWithdrawRSCReturn = [UseWithdrawRSCState, WithdrawRSCFn]; +type ResetFn = () => void; +type UseWithdrawRSCReturn = [UseWithdrawRSCState, WithdrawRSCFn, ResetFn]; + +interface UseWithdrawRSCOptions { + network?: NetworkType; +} /** * Hook for withdrawing RSC to a wallet address. * Manages transaction state and handles API interaction. * + * @param options - Options including network selection * @returns A tuple containing withdrawal state and function */ -export function useWithdrawRSC(): UseWithdrawRSCReturn { +export function useWithdrawRSC(options: UseWithdrawRSCOptions = {}): UseWithdrawRSCReturn { + const { network = 'BASE' } = options; const [txStatus, setTxStatus] = useState({ state: 'idle' }); const [fee, setFee] = useState(null); const [isFeeLoading, setIsFeeLoading] = useState(true); const [feeError, setFeeError] = useState(null); - // Fetch the transaction fee when the hook is initialized + // Fetch the transaction fee when the hook is initialized or network changes useEffect(() => { const fetchTransactionFee = async () => { try { setIsFeeLoading(true); setFeeError(null); - const feeAmount = await TransactionService.getWithdrawalFee(); + const feeAmount = await TransactionService.getWithdrawalFee(network); setFee(feeAmount); } catch (error) { let errorMessage = 'Failed to fetch transaction fee'; @@ -63,7 +71,7 @@ export function useWithdrawRSC(): UseWithdrawRSCReturn { }; fetchTransactionFee(); - }, []); + }, [network]); const withdrawRSC = async ( withdrawalData: Omit @@ -108,6 +116,10 @@ export function useWithdrawRSC(): UseWithdrawRSCReturn { } }; + const reset = () => { + setTxStatus({ state: 'idle' }); + }; + return [ { txStatus, @@ -117,5 +129,6 @@ export function useWithdrawRSC(): UseWithdrawRSCReturn { feeError, }, withdrawRSC, + reset, ]; } diff --git a/public/ethereum-logo.svg b/public/ethereum-logo.svg new file mode 100644 index 000000000..c9a9a8d0b --- /dev/null +++ b/public/ethereum-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/sw.js b/public/sw.js index f66066964..fc699ed4b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -7,8 +7,8 @@ if (!self.define) { new Promise((i) => { if ('document' in self) { const e = document.createElement('script'); - (e.src = t), (e.onload = i), document.head.appendChild(e); - } else (e = t), importScripts(t), i(); + ((e.src = t), (e.onload = i), document.head.appendChild(e)); + } else ((e = t), importScripts(t), i()); }).then(() => { let e = i[t]; if (!e) throw new Error(`Module ${t} didn’t register its module`); @@ -26,7 +26,7 @@ if (!self.define) { } define(['./workbox-4754cb34'], function (e) { 'use strict'; - importScripts(), + (importScripts(), self.skipWaiting(), e.clientsClaim(), e.precacheAndRoute( @@ -656,5 +656,5 @@ define(['./workbox-4754cb34'], function (e) { plugins: [new e.ExpirationPlugin({ maxEntries: 32, maxAgeSeconds: 3600 })], }), 'GET' - ); + )); }); diff --git a/services/transaction.service.ts b/services/transaction.service.ts index b46dbd56a..5b6dd3a13 100644 --- a/services/transaction.service.ts +++ b/services/transaction.service.ts @@ -89,14 +89,15 @@ export class TransactionService { private static readonly PURCHASE_PATH = '/api/purchase/'; // Define purchase path /** - * Fetches the current transaction fee for withdrawal on the BASE network + * Fetches the current transaction fee for withdrawal on the specified network + * @param network - The network to fetch the fee for ('BASE' or 'ETHEREUM') * @returns The transaction fee amount in RSC * @throws Error when the API request fails */ - static async getWithdrawalFee(): Promise { + static async getWithdrawalFee(network: 'BASE' | 'ETHEREUM' = 'BASE'): Promise { try { const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}${this.WITHDRAWAL_PATH}/transaction_fee/?network=BASE`, + `${process.env.NEXT_PUBLIC_API_URL}${this.WITHDRAWAL_PATH}/transaction_fee/?network=${network}`, { method: 'GET', headers: { diff --git a/utils/stringUtils.ts b/utils/stringUtils.ts index d055ab88c..89b96bd51 100644 --- a/utils/stringUtils.ts +++ b/utils/stringUtils.ts @@ -64,3 +64,29 @@ export const countWords = (text: string): number => { const trimmed = text.trim(); return trimmed ? trimmed.split(/\s+/).length : 0; }; + +/** + * Validates if a string is a valid Ethereum address format + * @param address The address string to validate + * @returns true if the address is a valid Ethereum address (0x followed by 40 hex characters) + */ +export const isValidEthereumAddress = (address: string): boolean => { + return /^0x[a-fA-F0-9]{40}$/.test(address); +}; + +/** + * Masks an Ethereum address by showing only the first few and last few characters + * @param address The address to mask (e.g., "0x1234567890abcdef1234567890abcdef12345678") + * @param prefixLength The number of characters to show at the start (default: 6) + * @param suffixLength The number of characters to show at the end (default: 4) + * @returns The masked address (e.g., "0x1234...5678") or empty string if address is invalid + */ +export const maskAddress = ( + address: string | null | undefined, + prefixLength: number = 6, + suffixLength: number = 4 +): string => { + if (!address) return ''; + if (address.length <= prefixLength + suffixLength) return address; + return `${address.slice(0, prefixLength)}...${address.slice(-suffixLength)}`; +};