Published on

技术栈整合:Ethers.js与钱包连接方案实践

第四篇:Web3技术栈整合

Web3技术栈整合:Ethers.js与钱包连接方案实践

Web3前端开发的核心在于与区块链的交互,这包括钱包连接、合约调用和交易处理等关键功能。在我的项目中,我选择了Ethers.js作为以太坊交互库,并结合Web3-React实现钱包连接管理,形成了一套完整的Web3技术栈解决方案。

技术选型思考

为什么选择Ethers.js?

在Web3开发中,有两个主要的以太坊交互库:Web3.js和Ethers.js。我选择Ethers.js主要基于以下考虑:

  • 模块化设计:核心功能与插件分离,减小打包体积
  • 类型安全:TypeScript支持更完善
  • 安全性:内置的安全特性,如防止重放攻击
  • API设计:更符合现代JavaScript习惯
  • 维护活跃度:开发团队更新频繁,问题修复及时

钱包连接方案选择

对于钱包连接管理,我选择了Web3-React库:

  • 多钱包支持:支持MetaMask、WalletConnect等主流钱包
  • React集成:专为React设计的API
  • 状态管理:内置的连接状态管理
  • 类型支持:完善的TypeScript类型定义

核心配置

1. 安装依赖

# Ethers.js核心库
pnpm add ethers@5.7.2

# Web3-React相关
pnpm add @web3-react/core@8.2.3 @web3-react/types@8.2.3
pnpm add @web3-react/metamask@8.2.4 @web3-react/walletconnect-v2@8.5.6

2. 钱包连接器配置

首先创建MetaMask连接器:

// src/connectors/metaMask.ts
import { MetaMask } from '@web3-react/metamask';
import { initializeConnector } from '@web3-react/core';

// 初始化MetaMask连接器
export const [metaMask, hooks] = initializeConnector<MetaMask>(
  actions => new MetaMask({ actions }),
);

// 支持的链ID配置
export const SUPPORTED_CHAIN_IDS = {
  MAINNET: 1,
  GOERLI: 5,
  SEPOLIA: 11155111,
  POLYGON: 137,
  MUMBAI: 80001,
};

// 链配置信息
export const CHAIN_INFO = {
  [SUPPORTED_CHAIN_IDS.MAINNET]: {
    name: 'Ethereum',
    currency: 'ETH',
    explorerUrl: 'https://etherscan.io',
    rpcUrl: 'https://mainnet.infura.io/v3/YOUR_INFURA_KEY',
  },
  [SUPPORTED_CHAIN_IDS.SEPOLIA]: {
    name: 'Sepolia',
    currency: 'ETH',
    explorerUrl: 'https://sepolia.etherscan.io',
    rpcUrl: 'https://sepolia.infura.io/v3/YOUR_INFURA_KEY',
  },
  [SUPPORTED_CHAIN_IDS.POLYGON]: {
    name: 'Polygon',
    currency: 'MATIC',
    explorerUrl: 'https://polygonscan.com',
    rpcUrl: 'https://polygon-rpc.com',
  },
};

然后配置WalletConnect:

// src/connectors/walletConnect.ts
import { WalletConnect as WalletConnectV2 } from '@web3-react/walletconnect-v2';
import { initializeConnector } from '@web3-react/core';
import { SUPPORTED_CHAIN_IDS } from './metaMask';

// 初始化WalletConnect连接器
export const [walletConnect, hooks] = initializeConnector<WalletConnectV2>(
  actions =>
    new WalletConnectV2({
      actions,
      options: {
        projectId: 'YOUR_WALLET_CONNECT_PROJECT_ID',
        chains: Object.values(SUPPORTED_CHAIN_IDS),
        showQrModal: true,
        metadata: {
          name: 'FL Web3 Interface',
          description: 'Web3应用示例',
          url: 'https://your-app-url.com',
          icons: ['https://your-app-url.com/icon.png'],
        },
      },
    }),
);

3. Web3 Provider配置

创建Web3 Provider组件,为整个应用提供Web3上下文:

// src/providers/Web3Provider.tsx
import { Web3ReactProvider, createWeb3ReactRoot } from '@web3-react/core';
import { ReactNode, useMemo } from 'react';
import { ethers } from 'ethers';

// 创建自定义的Web3React根
const Web3ProviderNetwork = createWeb3ReactRoot('NETWORK');

// 获取Ethers.js Provider
function getLibrary(provider: any): ethers.providers.Web3Provider {
  const library = new ethers.providers.Web3Provider(provider);
  library.pollingInterval = 12000; // 设置轮询间隔
  return library;
}

interface Web3ProviderProps {
  children: ReactNode;
}

export const Web3Provider = ({ children }: Web3ProviderProps) => {
  // 为网络连接创建provider
  const networkLibrary = useMemo(
    () => new ethers.providers.JsonRpcProvider('https://mainnet.infura.io/v3/YOUR_INFURA_KEY'),
    []
  );

  return (
    <Web3ReactProvider getLibrary={getLibrary}>
      <Web3ProviderNetwork getLibrary={() => networkLibrary}>
        {children}
      </Web3ProviderNetwork>
    </Web3ReactProvider>
  );
};

核心功能实现

1. 钱包连接组件

// src/components/WalletConnector.tsx
import { useState } from 'react';
import { Button, Menu, MenuItem, Typography, Box } from '@mui/material';
import { AccountBalanceWallet, ExpandMore } from '@mui/icons-material';
import { useWeb3React } from '@web3-react/core';
import { metaMask } from '../connectors/metaMask';
import { walletConnect } from '../connectors/walletConnect';
import { formatAddress } from '../utils/web3';

const WalletConnector = () => {
  const { account, active, deactivate } = useWeb3React();
  const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);

  const handleClick = (event: React.MouseEvent<HTMLElement>) => {
    setAnchorEl(event.currentTarget);
  };

  const handleClose = () => {
    setAnchorEl(null);
  };

  // 连接MetaMask
  const connectMetaMask = async () => {
    try {
      await metaMask.activate();
    } catch (error) {
      console.error('Failed to connect MetaMask:', error);
    } finally {
      handleClose();
    }
  };

  // 连接WalletConnect
  const connectWalletConnect = async () => {
    try {
      await walletConnect.activate();
    } catch (error) {
      console.error('Failed to connect WalletConnect:', error);
    } finally {
      handleClose();
    }
  };

  // 断开连接
  const handleDisconnect = () => {
    if (active) {
      deactivate();
    }
    handleClose();
  };

  return (
    <>
      {!active ? (
        <Button
          variant="contained"
          color="primary"
          startIcon={<AccountBalanceWallet />}
          endIcon={<ExpandMore />}
          onClick={handleClick}
          className="btn-primary"
        >
          连接钱包
        </Button>
      ) : (
        <Button
          variant="outlined"
          onClick={handleClick}
          className="border-primary-500 text-primary-500"
        >
          {formatAddress(account)}
          <ExpandMore className="ml-1" />
        </Button>
      )}

      <Menu
        anchorEl={anchorEl}
        open={Boolean(anchorEl)}
        onClose={handleClose}
        anchorOrigin={{
          vertical: 'bottom',
          horizontal: 'right',
        }}
        transformOrigin={{
          vertical: 'top',
          horizontal: 'right',
        }}
      >
        {!active ? (
          <>
            <MenuItem onClick={connectMetaMask}>
              <Box className="flex items-center">
                <img 
                  src="/metamask-icon.png" 
                  alt="MetaMask" 
                  className="w-5 h-5 mr-2" 
                />
                <Typography>MetaMask</Typography>
              </Box>
            </MenuItem>
            <MenuItem onClick={connectWalletConnect}>
              <Box className="flex items-center">
                <img 
                  src="/walletconnect-icon.png" 
                  alt="WalletConnect" 
                  className="w-5 h-5 mr-2" 
                />
                <Typography>WalletConnect</Typography>
              </Box>
            </MenuItem>
          </>
        ) : (
          <>
            <MenuItem disabled>
              <Typography variant="body2" className="text-neutral-500">
                当前账户
              </Typography>
              <Typography className="font-mono text-sm">
                {formatAddress(account)}
              </Typography>
            </MenuItem>
            <MenuItem onClick={handleDisconnect} className="text-red-500">
              断开连接
            </MenuItem>
          </>
        )}
      </Menu>
    </>
  );
};

export default WalletConnector;

2. 合约交互Hook

// src/hooks/useContract.ts
import { useMemo } from 'react';
import { useWeb3React } from '@web3-react/core';
import { ethers } from 'ethers';
import ERC20_ABI from '../abis/ERC20.json';
import RED_PACKET_ABI from '../abis/RedPacket.json';

// 合约地址配置
const CONTRACT_ADDRESSES = {
  [1]: { // 主网
    redPacket: '0xYourMainnetContractAddress',
  },
  [11155111]: { // Sepolia测试网
    redPacket: '0xYourSepoliaContractAddress',
    testToken: '0xTestTokenAddress',
  },
};

// 通用合约Hook
export const useContract = (address: string, abi: any, withSignerIfPossible = true) => {
  const { library, account } = useWeb3React();

  return useMemo(() => {
    if (!address || !abi || !library) return null;
    
    try {
      return withSignerIfPossible && account 
        ? new ethers.Contract(address, abi, library.getSigner(account))
        : new ethers.Contract(address, abi, library);
    } catch (error) {
      console.error('Failed to get contract:', error);
      return null;
    }
  }, [address, abi, library, account, withSignerIfPossible]);
};

// ERC20代币合约Hook
export const useERC20Contract = (tokenAddress: string, withSignerIfPossible = true) => {
  return useContract(tokenAddress, ERC20_ABI, withSignerIfPossible);
};

// 红包合约Hook
export const useRedPacketContract = (chainId: number | undefined, withSignerIfPossible = true) => {
  const address = chainId ? CONTRACT_ADDRESSES[chainId]?.redPacket : undefined;
  return useContract(address || '', RED_PACKET_ABI, withSignerIfPossible);
};

// 测试代币合约Hook
export const useTestTokenContract = (chainId: number | undefined, withSignerIfPossible = true) => {
  const address = chainId ? CONTRACT_ADDRESSES[chainId]?.testToken : undefined;
  return useContract(address || '', ERC20_ABI, withSignerIfPossible);
};

3. 交易处理Hook

// src/hooks/useTransaction.ts
import { useState, useCallback } from 'react';
import { ethers } from 'ethers';
import { useWeb3React } from '@web3-react/core';

type TransactionStatus = 'idle' | 'pending' | 'success' | 'failed';

export const useTransaction = () => {
  const { library, account } = useWeb3React();
  const [status, setStatus] = useState<TransactionStatus>('idle');
  const [txHash, setTxHash] = useState<string>('');
  const [error, setError] = useState<Error | null>(null);
  const [loadingText, setLoadingText] = useState<string>('处理中...');

  // 发送交易的通用方法
  const sendTransaction = useCallback(async (
    transaction: ethers.providers.TransactionRequest,
    loadingMsg = '处理中...'
  ) => {
    if (!library || !account) {
      setError(new Error('钱包未连接'));
      setStatus('failed');
      return;
    }

    setStatus('pending');
    setLoadingText(loadingMsg);
    setError(null);
    
    try {
      // 估算gas
      const gasLimit = await library.estimateGas({
        ...transaction,
        from: account,
      });

      // 发送交易
      const txResponse = await library.getSigner(account).sendTransaction({
        ...transaction,
        gasLimit: gasLimit.mul(120).div(100), // 增加20%的gas余量
      });
      
      setTxHash(txResponse.hash);
      setLoadingText('等待确认...');
      
      // 等待交易确认
      const receipt = await txResponse.wait();
      
      if (receipt.status === 1) {
        setStatus('success');
        return receipt;
      } else {
        throw new Error('交易失败');
      }
    } catch (err) {
      const error = err as Error;
      setError(error);
      setStatus('failed');
      throw error;
    }
  }, [library, account]);

  // 调用合约方法
  const callContractMethod = useCallback(async (
    contract: ethers.Contract,
    methodName: string,
    args: any[] = [],
    overrides: ethers.providers.TransactionRequest = {},
    loadingMsg = '处理中...'
  ) => {
    if (!contract) {
      setError(new Error('合约未初始化'));
      setStatus('failed');
      return;
    }

    setStatus('pending');
    setLoadingText(loadingMsg);
    setError(null);
    
    try {
      // 获取合约方法
      const method = contract[methodName];
      if (typeof method !== 'function') {
        throw new Error(`合约方法 ${methodName} 不存在`);
      }

      // 如果是只读方法,直接调用
      if (method.callStatic) {
        const result = await method(...args);
        setStatus('success');
        return result;
      }

      // 发送交易
      const txResponse = await method(...args, overrides);
      setTxHash(txResponse.hash);
      setLoadingText('等待确认...');
      
      // 等待交易确认
      const receipt = await txResponse.wait();
      
      if (receipt.status === 1) {
        setStatus('success');
        return receipt;
      } else {
        throw new Error('交易失败');
      }
    } catch (err) {
      const error = err as Error;
      setError(error);
      setStatus('failed');
      throw error;
    }
  }, []);

  // 重置状态
  const reset = useCallback(() => {
    setStatus('idle');
    setTxHash('');
    setError(null);
    setLoadingText('处理中...');
  }, []);

  return {
    sendTransaction,
    callContractMethod,
    reset,
    status,
    txHash,
    error,
    loadingText,
    isPending: status === 'pending',
    isSuccess: status === 'success',
    isFailed: status === 'failed',
  };
};

实际应用示例

红包创建组件

// src/components/CreateRedPacket.tsx
import { useState } from 'react';
import { TextField, Button, Typography, Box, InputAdornment } from '@mui/material';
import { useWeb3React } from '@web3-react/core';
import { useRedPacketContract } from '../hooks/useContract';
import { useTransaction } from '../hooks/useTransaction';
import { parseEther } from '../utils/web3';

const CreateRedPacket = () => {
  const { chainId } = useWeb3React();
  const redPacketContract = useRedPacketContract(chainId);
  const { callContractMethod, isPending, txHash, isSuccess, error } = useTransaction();
  
  const [formData, setFormData] = useState({
    amount: '',
    count: '',
    password: '',
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!redPacketContract) {
      alert('合约未初始化,请检查网络连接');
      return;
    }
    
    try {
      // 转换金额为wei
      const amountWei = parseEther(formData.amount);
      const count = parseInt(formData.count);
      
      if (isNaN(count) || count <= 0) {
        alert('请输入有效的红包数量');
        return;
      }
      
      // 调用合约创建红包
      await callContractMethod(
        redPacketContract,
        'createRedPacket',
        [count, formData.password],
        { value: amountWei },
        '创建红包中...'
      );
      
      if (isSuccess) {
        alert('红包创建成功!');
        setFormData({ amount: '', count: '', password: '' });
      }
    } catch (err) {
      console.error('创建红包失败:', err);
    }
  };

  return (
    <Box component="form" onSubmit={handleSubmit} className="space-y-4">
      <Typography variant="h6" className="mb-4">
        创建红包
      </Typography>
      
      <TextField
        label="总金额 (ETH)"
        name="amount"
        type="number"
        value={formData.amount}
        onChange={handleChange}
        required
        fullWidth
        InputProps={{
          endAdornment: <InputAdornment position="end">ETH</InputAdornment>,
        }}
        disabled={isPending}
      />
      
      <TextField
        label="红包数量"
        name="count"
        type="number"
        value={formData.count}
        onChange={handleChange}
        required
        fullWidth
        disabled={isPending}
      />
      
      <TextField
        label="红包密码"
        name="password"
        type="password"
        value={formData.password}
        onChange={handleChange}
        required
        fullWidth
        disabled={isPending}
      />
      
      <Button
        type="submit"
        variant="contained"
        color="primary"
        fullWidth
        disabled={isPending}
        className="btn-primary py-2"
      >
        {isPending ? '创建中...' : '创建红包'}
      </Button>
      
      {txHash && (
        <Typography variant="body2" className="text-center mt-2">
          交易哈希: 
          <a 
            href={`https://sepolia.etherscan.io/tx/${txHash}`} 
            target="_blank" 
            rel="noopener noreferrer"
            className="text-primary-500 ml-1"
          >
            {txHash.substring(0, 10)}...
          </a>
        </Typography>
      )}
      
      {error && (
        <Typography color="error" className="text-center mt-2">
          {error.message}
        </Typography>
      )}
    </Box>
  );
};

export default CreateRedPacket;

Web3开发最佳实践

在整合Web3技术栈的过程中,我总结了以下最佳实践:

1. 错误处理

  • 为每个操作提供清晰的错误提示
  • 捕获并处理用户拒绝交易的情况
  • 提供交易失败的原因分析

2. 用户体验

  • 显示交易处理的进度状态
  • 提供交易哈希的链接
  • 处理网络切换和钱包断开的情况

3. 安全性

  • 验证用户输入,防止恶意数据
  • 使用合适的gas策略
  • 避免在前端存储敏感信息

4. 性能优化

  • 缓存合约实例和ABI
  • 使用批量请求减少RPC调用
  • 优化链数据的获取和更新

遇到的挑战

  1. 多链支持:不同网络的合约地址管理
  2. 钱包兼容性:不同钱包的行为差异处理
  3. Gas优化:动态调整gas策略
  4. 用户体验:交易等待和反馈机制

总结

Web3技术栈的整合是区块链应用开发的核心环节。通过合理选择工具库和精心设计架构,可以构建出功能完善、用户体验良好的Web3应用。在实际开发中,我注重代码的可维护性和扩展性,同时关注用户体验和安全性,力求打造出高质量的区块链前端产品。

下一篇,我将分享测试体系的建设经验,包括单元测试与E2E测试的最佳实践。