分类: DApp开发

  • DApp去中心化存储实战:IPFS与Filecoin集成开发指南

    DApp去中心化存储实战:IPFS与Filecoin集成开发指南

    一、引言:为什么DApp需要去中心化存储

    构建去中心化应用时,许多开发者会遇到一个根本性问题:智能合约虽然能保证核心逻辑的不可篡改性,但链上存储成本高昂且容量受限。以以太坊为例,存储1KB数据的Gas费用约为0.001ETH(约3-5美元),这意味着如果你想在链上存储一张普通的用户头像,仅这一项操作就需要消耗用户数十美元的费用。

    传统解决方案是使用AWS S3或阿里云OSS等中心化存储服务,但这样做会引入一个尴尬的局面:你的DApp虽然名字里带着“去中心化”,但用户数据实际上存储在某家科技公司的服务器上。一旦服务器宕机、DNS被污染或服务提供商倒闭,你的DApp就会变成“无根之木”,用户的所有数据都将面临丢失风险。

    IPFS与Filecoin分层存储架构,从Filecoin冷存储到IPFS热数据再到链上元数据的三层体系

    IPFS(星际文件系统)和Filecoin的组合提供了一个优雅的解决方案:通过“链上存CID、链下存数据”的混合架构,我们既能享受去中心化的安全特性,又能控制存储成本。本文将手把手教你如何在DApp中集成这套存储系统。

    二、IPFS核心概念与内容寻址原理

    2.1 从位置寻址到内容寻址

    传统Web使用HTTP协议进行“位置寻址”(Location Addressing)。当你访问一张图片时,URL指向的是存放这张图片的具体服务器地址:

    plaintext

    https://example.com/images/avatar.jpg
    

    这种方式的问题在于:如果服务器关闭、图片被删除或URL被屏蔽,你就再也无法访问这张图片。

    IPFS采用了完全不同的“内容寻址”(Content Addressing)机制。文件上传到IPFS网络后,系统会根据文件内容生成一个唯一的哈希值,称为CID(Content Identifier)。这个哈希值就像文件的“数字指纹”,完全由内容本身决定:

    plaintext

    ipfs://QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco
    

    当你需要访问这个文件时,网络中的任何节点只要存储了这份数据,都可以为你提供检索服务。CID本身就是地址,只要有人在网络中保存这份数据,你就能访问它——这从根本上消除了单点故障。

    2.2 固定(Pinning)与数据持久性

    需要特别注意的是:IPFS网络本身不保证数据的永久存储。当一个文件上传后,如果没有任何节点主动“固定”它,随着时间推移,垃圾回收机制会逐渐清理那些无人问津的数据。

    这就引出了两种常见的固定策略:

    主动固定(Self-Pinning):你自己运行IPFS节点并固定重要数据。这种方式控制力最强,但需要自行维护节点基础设施。

    第三方服务固定:使用Pinata、Web3.Storage等服务商提供的固定服务。这些服务会确保你的数据始终保持可用,你只需为存储空间付费,无需操心节点运维。

    三、Filecoin激励层与长期存储保障

    IPFS解决了“如何存储”的问题,但需要Filecoin来解决“为何愿意存储”的问题。

    Filecoin是基于IPFS协议构建的去中心化存储市场,它通过经济激励机制让矿工愿意长期存储数据。矿工通过提供存储服务获得FIL代币奖励,而需要存储空间的开发者则可以花费FIL雇佣矿工。

    这套机制对DApp开发者的实际意义在于:

    成本优势:Filecoin存储成本约为中心化云存储的1/10。对于需要存储大量数据的DApp(如NFT元数据、GameFi游戏资源),这能节省可观的费用。

    可靠性保障:矿工必须提供加密证明(Proof-of-Spacetime)证明他们确实在持续存储数据,否则将无法获得奖励。这从根本上保障了数据的持久性。

    冗余备份:数据通常会分片存储在多个独立矿工节点上,单一节点故障不会影响数据的整体可用性。

    四、实战:Next.js DApp集成IPFS与Filecoin

    4.1 项目初始化与环境配置

    首先创建一个新的React项目(这里使用Next.js框架,它对SSR和API路由有良好支持):

    bash

    npx create-next-app@latest my-web3-dapp --typescript --tailwind
    cd my-web3-dapp
    npm install web3.storage @glif/filecoin-address
    

    接下来配置Web3.Storage客户端。Web3.Storage是Protocol Labs提供的服务,它将IPFS和Filecoin封装成了易于使用的API:

    typescript

    // src/lib/storage.ts
    import { Web3Storage } from 'web3.storage'
    
    function getStorageClient() {
      // 从环境变量获取API Token
      // 申请地址:https://web3.storage/
      const token = process.env.NEXT_PUBLIC_WEB3_STORAGE_TOKEN
      
      if (!token) {
        throw new Error('Missing Web3.Storage API Token')
      }
      
      return new Web3Storage({ token })
    }
    
    export async function uploadToIPFS(files: File[]) {
      const client = getStorageClient()
      
      try {
        const cid = await client.put(files, {
          name: `dapp-upload-${Date.now()}`,
          maxRetries: 3
        })
        
        return {
          success: true,
          cid,
          ipfsUrl: `https://${cid}.ipfs.dweb.link/`,
          gatewayUrl: `https://w3s.link/ipfs/${cid}/`
        }
      } catch (error) {
        console.error('Upload failed:', error)
        return {
          success: false,
          error: error instanceof Error ? error.message : 'Unknown error'
        }
      }
    }
    

    4.2 文件上传组件开发

    现在创建一个支持拖拽上传的文件组件:

    tsx

    // src/components/FileUploader.tsx
    'use client'
    
    import { useState, useCallback } from 'react'
    import { uploadToIPFS } from '@/lib/storage'
    
    interface UploadResult {
      cid: string
      url: string
      filename: string
    }
    
    export default function FileUploader() {
      const [files, setFiles] = useState<File[]>([])
      const [uploading, setUploading] = useState(false)
      const [uploadResult, setUploadResult] = useState<UploadResult[]>([])
      const [dragActive, setDragActive] = useState(false)
    
      const handleDrag = useCallback((e: React.DragEvent) => {
        e.preventDefault()
        e.stopPropagation()
        if (e.type === 'dragenter' || e.type === 'dragover') {
          setDragActive(true)
        } else if (e.type === 'dragleave') {
          setDragActive(false)
        }
      }, [])
    
      const handleDrop = useCallback((e: React.DragEvent) => {
        e.preventDefault()
        e.stopPropagation()
        setDragActive(false)
        
        const droppedFiles = Array.from(e.dataTransfer.files)
        setFiles(prev => [...prev, ...droppedFiles])
      }, [])
    
      const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
        if (e.target.files) {
          const selectedFiles = Array.from(e.target.files)
          setFiles(prev => [...prev, ...selectedFiles])
        }
      }
    
      const handleUpload = async () => {
        if (files.length === 0) return
        
        setUploading(true)
        const results: UploadResult[] = []
        
        for (const file of files) {
          const result = await uploadToIPFS([file])
          
          if (result.success && result.cid) {
            results.push({
              cid: result.cid,
              url: result.gatewayUrl || result.ipfsUrl,
              filename: file.name
            })
          }
        }
        
        setUploadResult(results)
        setUploading(false)
        
        if (results.length > 0) {
          console.log('Upload successful! CIDs stored in smart contract for verification.')
        }
      }
    
      return (
        <div className="w-full max-w-2xl mx-auto p-6">
          {/* 拖拽上传区域 */}
          <div
            onDragEnter={handleDrag}
            onDragLeave={handleDrag}
            onDragOver={handleDrag}
            onDrop={handleDrop}
            className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors
              ${dragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}`}
          >
            <input
              type="file"
              multiple
              onChange={handleFileSelect}
              className="hidden"
              id="file-upload"
            />
            <label htmlFor="file-upload" className="cursor-pointer">
              <div className="text-4xl mb-4">📁</div>
              <p className="text-lg font-medium text-gray-700">
                拖拽文件到此处,或点击选择
              </p>
              <p className="text-sm text-gray-500 mt-2">
                支持任意文件类型,数据将永久存储在IPFS + Filecoin网络
              </p>
            </label>
          </div>
    
          {/* 已选文件列表 */}
          {files.length > 0 && (
            <div className="mt-4">
              <h3 className="font-medium mb-2">已选择 {files.length} 个文件:</h3>
              <ul className="space-y-1">
                {files.map((file, index) => (
                  <li key={index} className="text-sm text-gray-600 flex items-center gap-2">
                    <span>📄</span>
                    <span className="truncate">{file.name}</span>
                    <span className="text-gray-400">({(file.size / 1024).toFixed(1)} KB)</span>
                  </li>
                ))}
              </ul>
            </div>
          )}
    
          {/* 上传按钮 */}
          <button
            onClick={handleUpload}
            disabled={files.length === 0 || uploading}
            className={`mt-6 w-full py-3 px-6 rounded-lg font-medium transition-all
              ${files.length === 0 || uploading
                ? 'bg-gray-300 cursor-not-allowed'
                : 'bg-blue-600 hover:bg-blue-700 text-white'}`}
          >
            {uploading ? '上传中...' : `上传 ${files.length} 个文件到去中心化存储`}
          </button>
    
          {/* 上传结果 */}
          {uploadResult.length > 0 && (
            <div className="mt-6 p-4 bg-green-50 rounded-lg border border-green-200">
              <h3 className="font-medium text-green-800 mb-3">✅ 上传成功!</h3>
              <div className="space-y-3">
                {uploadResult.map((result, index) => (
                  <div key={index} className="text-sm">
                    <p className="font-medium text-gray-700">{result.filename}</p>
                    <p className="text-gray-500 break-all">
                      CID: <code className="bg-gray-100 px-1 rounded">{result.cid}</code>
                    </p>
                    <a 
                      href={result.url} 
                      target="_blank" 
                      rel="noopener noreferrer"
                      className="text-blue-600 hover:underline"
                    >
                      在IPFS网关查看 →
                    </a>
                  </div>
                ))}
              </div>
            </div>
          )}
        </div>
      )
    }
    

    4.3 在智能合约中存储CID验证

    上传完成后,下一步是将CID存储到智能合约中,确保数据完整性和所有权验证:

    solidity

    // contracts/DecentralizedStorage.sol
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.19;
    
    import "@openzeppelin/contracts/access/Ownable.sol";
    import "@openzeppelin/contracts/utils/Counters.sol";
    
    contract DecentralizedStorage is Ownable {
        using Counters for Counters.Counter;
        
        // 文件存储结构
        struct StoredFile {
            string cid;           // IPFS内容标识符
            string filename;      // 原始文件名
            uint256 size;        // 文件大小
            uint256 timestamp;   // 上传时间戳
            address uploader;     // 上传者地址
            bool verified;        // 是否已验证存储
        }
        
        // 文件ID计数器
        Counters.Counter private _fileIds;
        
        // 文件映射:fileId => StoredFile
        mapping(uint256 => StoredFile) public files;
        
        // CID到fileId的映射,用于去重
        mapping(string => bool) public cidExists;
        
        // 事件
        event FileUploaded(
            uint256 indexed fileId,
            string cid,
            string filename,
            address indexed uploader
        );
        
        event FileVerified(
            uint256 indexed fileId,
            string cid,
            uint256 timestamp
        );
        
        /**
         * @dev 上传文件CID到链上
         * @param cid IPFS内容标识符
         * @param filename 文件名
         * @param size 文件大小
         */
        function uploadFileCID(
            string calldata cid,
            string calldata filename,
            uint256 size
        ) external returns (uint256) {
            require(bytes(cid).length > 0, "CID cannot be empty");
            require(bytes(filename).length > 0, "Filename cannot be empty");
            require(!cidExists[cid], "File already uploaded");
            
            _fileIds.increment();
            uint256 newFileId = _fileIds.current();
            
            files[newFileId] = StoredFile({
                cid: cid,
                filename: filename,
                size: size,
                timestamp: block.timestamp,
                uploader: msg.sender,
                verified: false
            });
            
            cidExists[cid] = true;
            
            emit FileUploaded(newFileId, cid, filename, msg.sender);
            
            return newFileId;
        }
        
        /**
         * @dev 验证文件已成功存储在Filecoin网络
         * @param fileId 文件ID
         */
        function verifyStorage(uint256 fileId) external onlyOwner {
            require(fileId > 0 && fileId <= _fileIds.current(), "Invalid file ID");
            require(!files[fileId].verified, "Already verified");
            
            files[fileId].verified = true;
            
            emit FileVerified(fileId, files[fileId].cid, block.timestamp);
        }
        
        /**
         * @dev 获取文件总数
         */
        function getTotalFiles() external view returns (uint256) {
            return _fileIds.current();
        }
        
        /**
         * @dev 获取用户上传的文件数量
         */
        function getUserFileCount(address user) external view returns (uint256) {
            uint256 count = 0;
            for (uint256 i = 1; i <= _fileIds.current(); i++) {
                if (files[i].uploader == user) {
                    count++;
                }
            }
            return count;
        }
    }
    

    五、智能合约与前端的完整交互

    完成智能合约部署后,我们需要编写TypeScript代码来连接合约并调用相关方法:

    typescript

    // src/lib/contracts.ts
    import { ethers } from 'ethers'
    
    // 合约ABI
    const CONTRACT_ABI = [
      "function uploadFileCID(string calldata cid, string calldata filename, uint256 size) external returns (uint256)",
      "function verifyStorage(uint256 fileId) external onlyOwner",
      "function files(uint256 fileId) external view returns (string memory cid, string memory filename, uint256 size, uint256 timestamp, address uploader, bool verified)",
      "function getTotalFiles() external view returns (uint256)",
      "function cidExists(string calldata cid) external view returns (bool)",
      "event FileUploaded(uint256 indexed fileId, string cid, string filename, address indexed uploader)"
    ]
    
    const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_STORAGE_CONTRACT_ADDRESS || ''
    
    export async function uploadToContract(
      cid: string,
      filename: string,
      size: number
    ): Promise<{ success: boolean; fileId?: number; error?: string }> {
      if (typeof window.ethereum === 'undefined') {
        return { success: false, error: 'Please install MetaMask' }
      }
    
      try {
        const provider = new ethers.BrowserProvider(window.ethereum)
        const signer = await provider.getSigner()
        const contract = new ethers.Contract(CONTRACT_ADDRESS, CONTRACT_ABI, signer)
    
        // 检查CID是否已存在
        const exists = await contract.cidExists(cid)
        if (exists) {
          return { success: false, error: 'This file has already been uploaded' }
        }
    
        const tx = await contract.uploadFileCID(cid, filename, size)
        const receipt = await tx.wait()
    
        // 从事件中提取fileId
        const event = receipt.logs.find(log => {
          try {
            const parsed = contract.interface.parseLog(log)
            return parsed?.name === 'FileUploaded'
          } catch {
            return false
          }
        })
    
        if (event) {
          const parsed = contract.interface.parseLog(event)
          const fileId = parsed?.args[0]
          return { success: true, fileId: Number(fileId) }
        }
    
        return { success: false, error: 'Failed to get file ID from transaction' }
      } catch (error) {
        console.error('Contract interaction error:', error)
        return {
          success: false,
          error: error instanceof Error ? error.message : 'Transaction failed'
        }
      }
    }
    
    export async function getFileInfo(fileId: number) {
      const provider = new ethers.JsonRpcProvider(
        process.env.NEXT_PUBLIC_RPC_URL || 'https://eth-sepolia.g.alchemy.com/v2/demo'
      )
      
      const contract = new ethers.Contract(CONTRACT_ADDRESS, CONTRACT_ABI, provider)
      
      try {
        const [cid, filename, size, timestamp, uploader, verified] = await contract.files(fileId)
        return {
          cid,
          filename,
          size: Number(size),
          timestamp: Number(timestamp),
          uploader,
          verified
        }
      } catch (error) {
        console.error('Failed to get file info:', error)
        return null
      }
    }
    

    六、架构最佳实践与性能优化

    6.1 分层存储策略

    在实际生产环境中,建议采用分层存储策略来平衡成本与性能:

    热数据层:存储在IPFS固定节点上的高频访问数据。使用Cloudflare IPFS Gateway或Pinata的专用网关,确保毫秒级响应。

    冷数据层:长期归档数据使用Filecoin存储。数据首次上传时通过Web3.Storage自动创建Filecoin交易,存储提供者会持续保存数据以获得区块奖励。

    元数据层:所有CID和业务关联信息存储在智能合约或去中心化数据库(如Ceramic Network)中,确保数据索引的可信性。

    6.2 数据隐私处理

    公开IPFS网络上的未加密数据可以被所有节点访问。如果你需要存储敏感信息(如用户身份文件、商业合同),务必在上传前进行端到端加密:

    typescript

    import { AES } from 'crypto-js'
    
    export async function uploadEncryptedFile(file: File, encryptionKey: string) {
      // 读取文件内容
      const arrayBuffer = await file.arrayBuffer()
      const wordArray = CryptoJS.lib.WordArray.create(arrayBuffer)
      
      // AES加密
      const encrypted = CryptoJS.AES.encrypt(wordArray.toString(CryptoJS.enc.Utf8), encryptionKey)
      
      // 将加密后的数据转换为Blob
      const encryptedBlob = new Blob([encrypted.toString()], { type: 'application/octet-stream' })
      const encryptedFile = new File([encryptedBlob], `${file.name}.enc`)
      
      // 上传到IPFS
      const result = await uploadToIPFS([encryptedFile])
      
      return {
        cid: result.cid,
        encrypted: true,
        message: 'Only users with the encryption key can decrypt this file'
      }
    }
    

    6.3 多网关冗余

    不要依赖单一IPFS网关。建议配置多个备用网关以提高可用性:

    typescript

    const IPFS_GATEWAYS = [
      'https://w3s.link/ipfs/',
      'https://cloudflare-ipfs.com/ipfs/',
      'https://ipfs.io/ipfs/',
      'https://gateway.pinata.cloud/ipfs/'
    ]
    
    export function getFileUrl(cid: string, filename: string): string {
      // 随机选择一个网关
      const gateway = IPFS_GATEWAYS[Math.floor(Math.random() * IPFS_GATEWAYS.length)]
      return `${gateway}${cid}/${filename}`
    }
    

    七、总结与展望

    通过本文的实战指南,你应该已经掌握了在DApp中集成IPFS和Filecoin的核心技能。这套存储方案的核心价值在于:

    真正的去中心化:数据不再依赖任何单一服务器或服务提供商,CID作为内容的“数字指纹”,只要有人在网络中保存这份数据,就能永久访问。

    显著的成本优势:相比链上存储,IPFS+Filecoin方案的成本降低超过99%,同时保持数据完整性和可验证性。

    架构灵活性:通过分层存储策略,你可以根据数据的访问频率和重要性灵活配置存储方案,平衡成本与性能。

    展望未来,随着Filecoin虚拟机(FVM)的成熟,我们有望看到更复杂的存储逻辑直接在Filecoin网络上执行——例如自动化的存储证明验证、动态存储费用结算等。这将进一步简化DApp开发者的工作,让去中心化存储真正成为Web3应用的默认选择。

    如果你在实践中遇到任何问题,欢迎在评论区交流讨论。我们下期再见!

    相关资源

  • ERC-4337账户抽象全面普及:Web3用户入门门槛大幅降低

    ERC-4337账户抽象全面普及:Web3用户入门门槛大幅降低

    为什么传统账户让人头疼

    以太坊有两种账户类型:外部拥有账户(EOA)和合约账户(CA)。

    EOA由私钥控制,必须自己支付Gas,不能有条件地执行操作,无法实现多重签名等复杂逻辑,丢失私钥就丢失一切。

    ERC-4337:把钱包变成智能合约

    ERC-4337的核心思想是:用智能合约替代EOA作为默认账户。

    在ERC-4337中,用户的”钱包”实际上是一个智能合约,叫作”账户合约”。这个合约定义了账户的逻辑:谁可以控制它、如何验证交易、如何执行操作。

    智能合约钱包:开发者IDE中ERC-4337合约代码与Paymaster实现

    关键组件包括:

    账户合约:用户的钱包本体,定义账户逻辑。可以实现多签、社交恢复、权限管理等高级功能。

    入口点合约:处理UserOp的标准化接口,确保不同账户实现之间的互操作性。

    Paymaster合约:允许第三方代付Gas费,或者用ERC-20代币支付Gas。这是实现”无Gas交易”的关键。

    无Gas交易:用户再也不需要懂Gas

    ERC-4337最吸引普通用户的功能是”无Gas交易”。

    开发者可以部署自己的Paymaster合约,替用户支付Gas费。常见的应用场景包括:应用内补贴(DeFi协议替用户支付Gas费作为获客手段)、Gas费代币化(用户可以直接用USDT等ERC-20代币支付Gas)。

    社交恢复:告别”一私钥走天下”

    ERC-4337的账户合约可以实现”社交恢复”功能:

    用户可以设定多个”监护人”。如果主私钥丢失,用户可以通过监护人的签名来重置账户密钥。整个过程在链上透明可验证,不需要中心化服务。

    可编程权限:企业级资产管理

    ERC-4337支持复杂的权限管理。

    比如,一个公司可以设置这样的账户规则:CEO可以审批任意交易,财务主管可以审批低于10 ETH的交易,超过10 ETH需要双重签名,超过100 ETH需要董事会决议。

    AI Agent与账户抽象:机器经济的基础

    AI Agent需要在链上自主执行操作,但传统的EOA模式对机器不友好。通过ERC-4337,AI Agent的钱包是一个智能合约:可由Paymaster代付Gas、可实现多重签名保护、可设置操作权限边界。

    这正是HashKey Group在白皮书中提到的”双Token”架构的技术基础。

    开发者建议

    对于DApp开发者:

    第一,拥抱账户抽象。提供Gas费代付可以显著提升转化率。

    第二,分层Gas策略。设计灵活的Gas方案:普通交易用用户Gas,高价值操作用协议补贴Gas。

    第三,账户抽象不只是”钱包升级”。它改变了用户与合约交互的方式。重新思考DApp的交互流程。

    第四,关注安全。智能合约钱包比EOA更复杂,可能有更多攻击面。确保账户合约经过充分审计。

    账户抽象的时代已经到来。抓住这个机会,你的产品将赢得下一代Web3用户。

  • 智能合约多签钱包开发实战:从零构建安全的多签机制

    智能合约多签钱包开发实战:从零构建安全的多签机制

    在加密货币世界里,资产安全始终是首要命题。对于个人投资者而言,助记词丢失是最大的风险来源;对于项目方、DAO金库或机构投资者而言,单点故障更是不可接受的安全隐患。多签钱包(Multi-Signature Wallet)正是为解决这些问题而生的核心基础设施。

    Gnosis Safe作为以太坊生态中最成功的多签钱包协议,管理着价值数百亿美元的资产。其设计理念和技术实现值得所有区块链开发者深入学习。本文将从零开始,完整实现一个功能完备的多签钱包合约。

    多签交易工作流程图,展示提交、确认、执行三阶段的完整生命周期

    多签钱包的核心设计原则

    为什么需要多签?

    传统单签钱包存在两个核心风险:

    1. 单点故障:私钥丢失或泄露意味着资产永久损失
    2. 权力集中:单一私钥持有者拥有完全控制权,无法实现权限分离

    多签钱包通过”M-of-N”机制解决这些问题:N个私钥持有者中,至少需要M个确认才能执行交易。这意味着:

    • 即使1个私钥丢失,只要M≤N-1,资产仍然安全
    • 大额交易需要多人审批,避免内部欺诈
    • 策略性地分配签名权,实现权限层级管理

    Gnosis Safe的设计哲学

    Gnosis Safe的多签设计有几个关键原则:

    • 模块化:Safe本身是轻量核心,通过模块扩展功能
    • 交易队列:所有交易必须经过确认队列,避免丢失或遗漏
    • 执行延迟:可配置的延迟机制,给撤销操作留出窗口
    • 邀请制:新签名者需要现有签名者集体确认

    基础架构实现

    合约结构概览

    我们的多签钱包包含以下核心组件:

    plaintext

    MultiSigWallet
    ├── 状态变量:所有者列表、阈值、交易计数
    ├── 交易存储:pendingTransactions, executedTransactions
    ├── 核心函数:submitTransaction, confirmTransaction, executeTransaction
    └── 事件:提交、确认、执行、撤销、所有者变更
    

    基础版本实现

    solidity

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.24;
    
    /**
     * @title MultiSigWallet
     * @notice 基础版多签钱包合约
     * @dev 实现 M-of-N 多签机制
     */
    contract MultiSigWallet {
        // ============ 状态变量 ============
        
        // 所有者地址列表
        address[] public owners;
        
        // 地址是否为所有者
        mapping(address => bool) public isOwner;
        
        // M-of-N: 需要多少个确认才能执行
        uint256 public required;
        
        // 交易计数
        uint256 public transactionCount;
        
        // 交易结构
        struct Transaction {
            address to;           // 目标地址
            uint256 value;        // 转账金额(wei)
            bytes data;           // 调用数据
            bool executed;        // 是否已执行
            uint256 numConfirmations;  // 当前确认数
        }
        
        // 交易列表
        mapping(uint256 => Transaction) public transactions;
        
        // 交易确认记录:txId -> owner -> bool
        mapping(uint256 => mapping(address => bool)) public confirmations;
        
        // ============ 事件 ============
        
        event SubmitTransaction(
            address indexed owner,
            uint256 indexed txIndex,
            address indexed to,
            uint256 value,
            bytes data
        );
        
        event ConfirmTransaction(address indexed owner, uint256 indexed txIndex);
        
        event RevokeConfirmation(address indexed owner, uint256 indexed txIndex);
        
        event ExecuteTransaction(address indexed owner, uint256 indexed txIndex);
        
        event OwnerAdded(address indexed newOwner);
        
        event OwnerRemoved(address indexed removedOwner);
        
        event RequirementChanged(uint256 newRequirement);
        
        // ============ 修饰符 ============
        
        modifier onlyOwner() {
            require(isOwner[msg.sender], "Not an owner");
            _;
        }
        
        modifier txExists(uint256 _txIndex) {
            require(_txIndex < transactionCount, "Transaction does not exist");
            _;
        }
        
        modifier notExecuted(uint256 _txIndex) {
            require(!transactions[_txIndex].executed, "Already executed");
            _;
        }
        
        modifier notConfirmed(uint256 _txIndex) {
            require(!confirmations[_txIndex][msg.sender], "Already confirmed");
            _;
        }
        
        // ============ 构造函数 ============
        
        constructor(address[] memory _owners, uint256 _required) {
            require(_owners.length > 0, "Owners required");
            require(
                _required > 0 && _required <= _owners.length,
                "Invalid required number"
            );
            
            for (uint256 i = 0; i < _owners.length; i++) {
                address owner = _owners[i];
                require(owner != address(0), "Invalid owner");
                require(!isOwner[owner], "Owner not unique");
                
                isOwner[owner] = true;
                owners.push(owner);
            }
            
            required = _required;
        }
        
        // ============ 核心函数 ============
        
        /**
         * @notice 提交新交易
         */
        function submitTransaction(address _to, uint256 _value, bytes memory _data)
            public
            onlyOwner
            returns (uint256 txIndex)
        {
            txIndex = transactionCount;
            
            transactions[txIndex] = Transaction({
                to: _to,
                value: _value,
                data: _data,
                executed: false,
                numConfirmations: 0
            });
            
            transactionCount++;
            
            emit SubmitTransaction(msg.sender, txIndex, _to, _value, _data);
            
            // 自动确认自己的交易
            confirmTransaction(txIndex);
        }
        
        /**
         * @notice 确认交易
         */
        function confirmTransaction(uint256 _txIndex)
            public
            onlyOwner
            txExists(_txIndex)
            notExecuted(_txIndex)
            notConfirmed(_txIndex)
        {
            confirmations[_txIndex][msg.sender] = true;
            transactions[_txIndex].numConfirmations++;
            
            emit ConfirmTransaction(msg.sender, _txIndex);
            
            // 检查是否达到执行阈值
            if (transactions[_txIndex].numConfirmations >= required) {
                executeTransaction(_txIndex);
            }
        }
        
        /**
         * @notice 执行交易
         */
        function executeTransaction(uint256 _txIndex)
            public
            onlyOwner
            txExists(_txIndex)
            notExecuted(_txIndex)
        {
            Transaction storage transaction = transactions[_txIndex];
            
            require(
                transaction.numConfirmations >= required,
                "Not enough confirmations"
            );
            
            transaction.executed = true;
            
            (bool success, ) = transaction.to.call{value: transaction.value}(
                transaction.data
            );
            
            require(success, "Transaction execution failed");
            
            emit ExecuteTransaction(msg.sender, _txIndex);
        }
        
        /**
         * @notice 撤销确认
         */
        function revokeConfirmation(uint256 _txIndex)
            public
            onlyOwner
            txExists(_txIndex)
            notExecuted(_txIndex)
        {
            require(confirmations[_txIndex][msg.sender], "Not confirmed");
            
            confirmations[_txIndex][msg.sender] = false;
            transactions[_txIndex].numConfirmations--;
            
            emit RevokeConfirmation(msg.sender, _txIndex);
        }
        
        // ============ 所有者管理 ============
        
        /**
         * @notice 添加所有者
         */
        function addOwner(address _newOwner) public onlyOwner {
            require(_newOwner != address(0), "Invalid address");
            require(!isOwner[_newOwner], "Already an owner");
            
            isOwner[_newOwner] = true;
            owners.push(_newOwner);
            
            // 如果新的所有者数量超过阈值,自动调整阈值
            if (required < owners.length) {
                required++;
            }
            
            emit OwnerAdded(_newOwner);
        }
        
        /**
         * @notice 移除所有者
         */
        function removeOwner(address _owner) public onlyOwner {
            require(owners.length > 1, "Cannot remove last owner");
            require(isOwner[_owner], "Not an owner");
            require(required <= owners.length - 1, "Invalid required value");
            
            isOwner[_owner] = false;
            
            // 从数组中移除
            address[] memory newOwners = new address[](owners.length - 1);
            uint256 index = 0;
            for (uint256 i = 0; i < owners.length; i++) {
                if (owners[i] != _owner) {
                    newOwners[index] = owners[i];
                    index++;
                }
            }
            owners = newOwners;
            
            emit OwnerRemoved(_owner);
        }
        
        /**
         * @notice 修改阈值
         */
        function changeRequirement(uint256 _newRequired) public onlyOwner {
            require(
                _newRequired > 0 && _newRequired <= owners.length,
                "Invalid required value"
            );
            
            required = _newRequired;
            emit RequirementChanged(_newRequired);
        }
        
        // ============ 查询函数 ============
        
        function getOwners() public view returns (address[] memory) {
            return owners;
        }
        
        function getTransactionCount() public view returns (uint256) {
            return transactionCount;
        }
        
        function getConfirmations(uint256 _txIndex)
            public
            view
            returns (address[] memory)
        {
            address[] memory confirmers = new address[](
                transactions[_txIndex].numConfirmations
            );
            
            uint256 index = 0;
            for (uint256 i = 0; i < owners.length; i++) {
                if (confirmations[_txIndex][owners[i]]) {
                    confirmers[index] = owners[i];
                    index++;
                }
            }
            
            return confirmers;
        }
        
        function getPendingTransactions()
            public
            view
            returns (Transaction[] memory)
        {
            uint256 count = 0;
            for (uint256 i = 0; i < transactionCount; i++) {
                if (!transactions[i].executed) {
                    count++;
                }
            }
            
            Transaction[] memory pending = new Transaction[](count);
            uint256 index = 0;
            for (uint256 i = 0; i < transactionCount; i++) {
                if (!transactions[i].executed) {
                    pending[index] = transactions[i];
                    index++;
                }
            }
            
            return pending;
        }
        
        // ============ 接收ETH ============
        
        receive() external payable {}
    }
    

    增强版本:带时间锁的多签钱包

    时间锁机制

    基础多签钱包的一个缺陷是:一旦交易达到M个确认,可以立即执行。这意味着恶意签名者可以在”观察者”发现异常前完成攻击。

    时间锁机制通过引入延迟期来解决这个问题——交易提交后必须等待一定时间才能执行,给了各方撤销和干预的机会。

    solidity

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.24;
    
    /**
     * @title TimelockedMultiSig
     * @notice 带时间锁的多签钱包
     */
    contract TimelockedMultiSig {
        // ============ 时间锁配置 ============
        
        // 时间锁延迟(秒)
        uint256 public timelockDelay;
        
        // 每个操作类型可配置的延迟
        mapping(bytes32 => uint256) public operationDelays;
        
        // 交易状态
        enum TransactionState {
            None,
            Submitted,
            Confirmed,
            AwaitingExecution,
            Executed,
            Cancelled
        }
        
        struct TimelockedTransaction {
            address to;
            uint256 value;
            bytes data;
            bytes32 operationId;
            TransactionState state;
            uint256 submissionTime;
            uint256 requiredAfterTime;
            uint256 numConfirmations;
            address submitter;
            address[] confirmers;
        }
        
        // ============ 交易存储 ============
        
        uint256 public transactionCount;
        mapping(uint256 => TimelockedTransaction) public transactions;
        mapping(uint256 => mapping(address => bool)) public confirmations;
        mapping(uint256 => mapping(address => bool)) public rejections;
        
        // ============ 事件 ============
        
        event TransactionSubmitted(
            uint256 indexed txId,
            address indexed submitter,
            address to,
            uint256 value,
            bytes32 operationId
        );
        
        event TransactionConfirmed(
            uint256 indexed txId,
            address indexed confirmer
        );
        
        event TransactionRejected(
            uint256 indexed txId,
            address indexed rejecter
        );
        
        event TransactionReadyForExecution(
            uint256 indexed txId,
            uint256 executableAfterTime
        );
        
        event TransactionExecuted(uint256 indexed txId);
        
        event TransactionCancelled(uint256 indexed txId);
        
        event TimelockDelayChanged(uint256 oldDelay, uint256 newDelay);
        
        // ============ 修饰符 ============
        
        modifier onlyOwner() {
            require(isOwner[msg.sender], "Not owner");
            _;
        }
        
        modifier txExists(uint256 txId) {
            require(txId < transactionCount, "Tx not found");
            _;
        }
        
        modifier onlyPending(uint256 txId) {
            require(
                transactions[txId].state == TransactionState.Submitted ||
                transactions[txId].state == TransactionState.Confirmed,
                "Tx not pending"
            );
            _;
        }
        
        // 所有者相关
        address[] public owners;
        mapping(address => bool) public isOwner;
        uint256 public required;
        
        // ============ 构造函数 ============
        
        constructor(
            address[] memory _owners,
            uint256 _required,
            uint256 _timelockDelay
        ) {
            require(_owners.length > 0, "No owners");
            require(
                _required > 0 && _required <= _owners.length,
                "Invalid required"
            );
            
            for (uint256 i = 0; i < _owners.length; i++) {
                require(!isOwner[_owners[i]], "Duplicate owner");
                isOwner[_owners[i]] = true;
                owners.push(_owners[i]);
            }
            
            required = _required;
            timelockDelay = _timelockDelay;
            
            // 设置默认操作延迟
            operationDelays[bytes32(0)] = _timelockDelay; // 默认
        }
        
        // ============ 交易生命周期 ============
        
        /**
         * @notice 提交交易
         */
        function submitTransaction(
            address _to,
            uint256 _value,
            bytes calldata _data,
            bytes32 _operationId
        ) external onlyOwner returns (uint256 txId) {
            txId = transactionCount++;
            
            uint256 delay = operationDelays[_operationId];
            if (delay == 0) {
                delay = timelockDelay;
            }
            
            transactions[txId] = TimelockedTransaction({
                to: _to,
                value: _value,
                data: _data,
                operationId: _operationId,
                state: TransactionState.Submitted,
                submissionTime: block.timestamp,
                requiredAfterTime: block.timestamp + delay,
                numConfirmations: 1,
                submitter: msg.sender,
                confirmers: new address[](0)
            });
            
            confirmations[txId][msg.sender] = true;
            
            emit TransactionSubmitted(txId, msg.sender, _to, _value, _operationId);
        }
        
        /**
         * @notice 确认交易
         */
        function confirmTransaction(uint256 txId)
            external
            onlyOwner
            txExists(txId)
            onlyPending(txId)
        {
            require(!confirmations[txId][msg.sender], "Already confirmed");
            require(!rejections[txId][msg.sender], "Already rejected");
            
            confirmations[txId][msg.sender] = true;
            transactions[txId].confirmers.push(msg.sender);
            transactions[txId].numConfirmations++;
            
            emit TransactionConfirmed(txId, msg.sender);
            
            // 检查是否达到确认阈值
            if (transactions[txId].numConfirmations >= required) {
                _transitionToAwaitingExecution(txId);
            }
        }
        
        /**
         * @notice 否决交易
         */
        function rejectTransaction(uint256 txId)
            external
            onlyOwner
            txExists(txId)
            onlyPending(txId)
        {
            require(!rejections[txId][msg.sender], "Already rejected");
            
            rejections[txId][msg.sender] = true;
            
            // 如果有足够多的否决,可以直接取消
            uint256 rejectionCount = 0;
            for (uint256 i = 0; i < owners.length; i++) {
                if (rejections[txId][owners[i]]) {
                    rejectionCount++;
                }
            }
            
            if (rejectionCount >= owners.length - required + 1) {
                transactions[txId].state = TransactionState.Cancelled;
                emit TransactionCancelled(txId);
            }
            
            emit TransactionRejected(txId, msg.sender);
        }
        
        /**
         * @notice 过渡到可执行状态
         */
        function _transitionToAwaitingExecution(uint256 txId) internal {
            transactions[txId].state = TransactionState.AwaitingExecution;
            
            emit TransactionReadyForExecution(
                txId,
                transactions[txId].requiredAfterTime
            );
        }
        
        /**
         * @notice 执行交易(需等待时间锁)
         */
        function executeTransaction(uint256 txId)
            external
            onlyOwner
            txExists(txId)
        {
            TimelockedTransaction storage tx = transactions[txId];
            
            require(
                tx.state == TransactionState.AwaitingExecution,
                "Not ready for execution"
            );
            require(
                block.timestamp >= tx.requiredAfterTime,
                "Time lock not expired"
            );
            
            tx.state = TransactionState.Executed;
            
            (bool success, ) = tx.to.call{value: tx.value}(tx.data);
            require(success, "Execution failed");
            
            emit TransactionExecuted(txId);
        }
        
        /**
         * @notice 取消交易
         */
        function cancelTransaction(uint256 txId)
            external
            onlyOwner
            txExists(txId)
        {
            TimelockedTransaction storage tx = transactions[txId];
            
            require(
                tx.state == TransactionState.Submitted ||
                tx.state == TransactionState.Confirmed ||
                tx.state == TransactionState.AwaitingExecution,
                "Cannot cancel"
            );
            
            tx.state = TransactionState.Cancelled;
            emit TransactionCancelled(txId);
        }
        
        // ============ 管理函数 ============
        
        /**
         * @notice 设置时间锁延迟
         */
        function setTimelockDelay(uint256 _newDelay) external onlyOwner {
            require(_newDelay >= 1 days, "Delay too short");
            
            uint256 oldDelay = timelockDelay;
            timelockDelay = _newDelay;
            
            emit TimelockDelayChanged(oldDelay, _newDelay);
        }
        
        /**
         * @notice 设置特定操作的延迟
         */
        function setOperationDelay(bytes32 operationId, uint256 delay) 
            external 
            onlyOwner 
        {
            operationDelays[operationId] = delay;
        }
        
        /**
         * @notice 紧急执行(绕过时间锁,仅紧急情况使用)
         */
        function emergencyExecute(
            address _to,
            uint256 _value,
            bytes calldata _data
        ) external onlyOwner {
            require(owners.length >= 3, "Need at least 3 owners for emergency");
            
            // 紧急执行需要所有所有者的确认
            uint256 emergencyConfirmations = 0;
            for (uint256 i = 0; i < owners.length; i++) {
                if (isOwner[owners[i]]) {
                    emergencyConfirmations++;
                }
            }
            
            require(emergencyConfirmations == owners.length, "Need all confirmations");
            
            (bool success, ) = _to.call{value: _value}(_data);
            require(success, "Emergency execution failed");
        }
        
        // ============ 查询函数 ============
        
        function getOwners() external view returns (address[] memory) {
            return owners;
        }
        
        function getTransaction(uint256 txId) 
            external 
            view 
            returns (TimelockedTransaction memory) 
        {
            return transactions[txId];
        }
        
        function getPendingTransactions() external view returns (uint256[] memory) {
            uint256 count = 0;
            for (uint256 i = 0; i < transactionCount; i++) {
                if (transactions[i].state != TransactionState.Executed &&
                    transactions[i].state != TransactionState.Cancelled) {
                    count++;
                }
            }
            
            uint256[] memory pending = new uint256[](count);
            uint256 index = 0;
            for (uint256 i = 0; i < transactionCount; i++) {
                if (transactions[i].state != TransactionState.Executed &&
                    transactions[i].state != TransactionState.Cancelled) {
                    pending[index] = i;
                    index++;
                }
            }
            
            return pending;
        }
        
        // ============ 接收ETH ============
        
        receive() external payable {}
    }
    

    模块化扩展:DAO治理集成

    模块化设计的重要性

    Gnosis Safe的核心设计哲学是模块化。Safe本身只处理多签逻辑,所有额外功能(如角色管理、ERC20代币支持、DAO集成)都通过模块实现。

    让我们实现一个DAO治理模块,允许代币持有者参与多签决策:

    solidity

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.24;
    
    /**
     * @title GovernanceModule
     * @notice DAO治理模块:允许代币持有者投票决定多签操作
     */
    contract GovernanceModule {
        // ============ 状态 ============
        
        // 主钱包合约引用
        address public multiSigWallet;
        
        // 治理代币
        address public governanceToken;
        
        // 提案结构
        struct Proposal {
            address proposer;
            address to;
            uint256 value;
            bytes data;
            string description;
            uint256 startTime;
            uint256 duration;
            uint256 forVotes;
            uint256 againstVotes;
            bool executed;
            bool cancelled;
            mapping(address => bool) hasVoted;
            mapping(address => uint256) voteWeight;
        }
        
        // 提案列表
        mapping(uint256 => Proposal) public proposals;
        uint256 public proposalCount;
        
        // 提案状态
        enum ProposalState {
            Pending,
            Active,
            Succeeded,
            Defeated,
            Executed,
            Cancelled
        }
        
        // 投票门槛
        uint256 public votingThreshold;
        uint256 public votingDuration;
        
        // ============ 事件 ============
        
        event ProposalCreated(
            uint256 indexed proposalId,
            address indexed proposer,
            address to,
            uint256 value,
            string description
        );
        
        event VoteCast(
            uint256 indexed proposalId,
            address indexed voter,
            bool support,
            uint256 weight
        );
        
        event ProposalExecuted(uint256 indexed proposalId);
        
        event ProposalCancelled(uint256 indexed proposalId);
        
        // ============ 修饰符 ============
        
        modifier onlyGovernance() {
            require(msg.sender == multiSigWallet, "Only wallet");
            _;
        }
        
        // ============ 构造函数 ============
        
        constructor(
            address _multiSigWallet,
            address _governanceToken,
            uint256 _votingThreshold,
            uint256 _votingDuration
        ) {
            multiSigWallet = _multiSigWallet;
            governanceToken = _governanceToken;
            votingThreshold = _votingThreshold;
            votingDuration = _votingDuration;
        }
        
        // ============ 核心函数 ============
        
        /**
         * @notice 创建治理提案
         */
        function createProposal(
            address _to,
            uint256 _value,
            bytes calldata _data,
            string calldata _description
        ) external returns (uint256 proposalId) {
            require(bytes(_description).length > 0, "Empty description");
            
            proposalId = proposalCount++;
            Proposal storage proposal = proposals[proposalId];
            
            proposal.proposer = msg.sender;
            proposal.to = _to;
            proposal.value = _value;
            proposal.data = _data;
            proposal.description = _description;
            proposal.startTime = block.timestamp;
            proposal.duration = votingDuration;
            
            emit ProposalCreated(proposalId, msg.sender, _to, _value, _description);
        }
        
        /**
         * @notice 投票
         */
        function castVote(uint256 proposalId, bool support) external {
            Proposal storage proposal = proposals[proposalId];
            
            require(
                block.timestamp >= proposal.startTime &&
                block.timestamp < proposal.startTime + proposal.duration,
                "Voting not active"
            );
            require(!proposal.hasVoted[msg.sender], "Already voted");
            
            // 获取投票权重(持有代币数量)
            uint256 weight = IERC20(governanceToken).balanceOf(msg.sender);
            require(weight >= votingThreshold, "Below voting threshold");
            
            proposal.hasVoted[msg.sender] = true;
            proposal.voteWeight[msg.sender] = weight;
            
            if (support) {
                proposal.forVotes += weight;
            } else {
                proposal.againstVotes += weight;
            }
            
            emit VoteCast(proposalId, msg.sender, support, weight);
        }
        
        /**
         * @notice 查询提案状态
         */
        function getProposalState(uint256 proposalId) 
            public 
            view 
            returns (ProposalState) 
        {
            Proposal storage proposal = proposals[proposalId];
            
            if (proposal.executed) {
                return ProposalState.Executed;
            }
            if (proposal.cancelled) {
                return ProposalState.Cancelled;
            }
            if (block.timestamp < proposal.startTime) {
                return ProposalState.Pending;
            }
            if (block.timestamp < proposal.startTime + proposal.duration) {
                return ProposalState.Active;
            }
            if (proposal.forVotes > proposal.againstVotes) {
                return ProposalState.Succeeded;
            }
            return ProposalState.Defeated;
        }
        
        /**
         * @notice 执行通过的提案
         */
        function executeProposal(uint256 proposalId) external onlyGovernance {
            require(
                getProposalState(proposalId) == ProposalState.Succeeded,
                "Proposal not succeeded"
            );
            
            Proposal storage proposal = proposals[proposalId];
            proposal.executed = true;
            
            (bool success, ) = proposal.to.call{value: proposal.value}(proposal.data);
            require(success, "Execution failed");
            
            emit ProposalExecuted(proposalId);
        }
        
        /**
         * @notice 取消提案
         */
        function cancelProposal(uint256 proposalId) external {
            Proposal storage proposal = proposals[proposalId];
            require(msg.sender == proposal.proposer, "Not proposer");
            require(!proposal.executed, "Already executed");
            
            proposal.cancelled = true;
            emit ProposalCancelled(proposalId);
        }
    }
    
    // IERC20接口
    interface IERC20 {
        function balanceOf(address account) external view returns (uint256);
    }
    

    安全最佳实践

    重入攻击防护

    多签钱包经常处理ETH和代币转账,必须防范重入攻击:

    solidity

    // 安全转账模式
    function safeTransfer(address token, address to, uint256 amount) internal {
        (bool success, bytes memory data) = token.call(
            abi.encodeWithSelector(
                IERC20.transfer.selector,
                to,
                amount
            )
        );
        require(success && (data.length == 0 || abi.decode(data, (bool))),
            "Transfer failed");
    }
    
    // 遵循Checks-Effects-Interactions模式
    function executeTransactionSafe(uint256 txId) internal {
        Transaction storage tx = transactions[txId];
        
        // Checks
        require(tx.numConfirmations >= required, "Not enough confirmations");
        require(!tx.executed, "Already executed");
        
        // Effects
        tx.executed = true;
        
        // Interactions(最后执行外部调用)
        (bool success, ) = tx.to.call{value: tx.value}(tx.data);
        require(success, "Execution failed");
    }
    

    交易队列管理

    对于高频多签操作,建议实现交易队列:

    solidity

    // 交易队列:避免交易丢失或冲突
    contract TransactionQueue {
        struct QueuedTransaction {
            address to;
            uint256 value;
            bytes data;
            uint256 nonce;  // 唯一标识,防止重放
            uint256 expiresAt;
            bytes32 txHash;
        }
        
        mapping(bytes32 => QueuedTransaction) public queue;
        mapping(address => uint256) public nonces;
        
        function queueTransaction(
            address _to,
            uint256 _value,
            bytes calldata _data,
            uint256 _expiration
        ) internal returns (bytes32 txHash) {
            uint256 nonce = nonces[msg.sender]++;
            
            txHash = keccak256(abi.encodePacked(
                msg.sender,
                nonce,
                _to,
                _value,
                _data
            ));
            
            queue[txHash] = QueuedTransaction({
                to: _to,
                value: _value,
                data: _data,
                nonce: nonce,
                expiresAt: block.timestamp + _expiration,
                txHash: txHash
            });
        }
        
        function executeFromQueue(bytes32 txHash) internal {
            QueuedTransaction storage qt = queue[txHash];
            require(qt.expiresAt > block.timestamp, "Transaction expired");
            
            delete queue[txHash];
            
            (bool success, ) = qt.to.call{value: qt.value}(qt.data);
            require(success, "Execution failed");
        }
    }
    

    测试与验证

    Hardhat测试套件

    javascript

    const { expect } = require("chai");
    const { ethers } = require("hardhat");
    const { time } = require("@nomicfoundation/hardhat-network-helpers");
    
    describe("MultiSigWallet", function () {
      let wallet;
      let owners;
      let required = 2;
      
      beforeEach(async () => {
        [owner1, owner2, owner3, recipient] = await ethers.getSigners();
        owners = [owner1.address, owner2.address, owner3.address];
        
        const MultiSig = await ethers.getContractFactory("MultiSigWallet");
        wallet = await MultiSig.deploy(owners, required);
        await wallet.deployed();
        
        // 预存一些ETH用于测试
        await owner1.sendTransaction({
          to: wallet.address,
          value: ethers.utils.parseEther("10")
        });
      });
      
      it("Should accept deposits", async () => {
        const balance = await ethers.provider.getBalance(wallet.address);
        expect(balance).to.equal(ethers.utils.parseEther("10"));
      });
      
      it("Should submit and auto-confirm transaction", async () => {
        const txIndex = 0;
        
        await wallet.connect(owner1).submitTransaction(
          recipient.address,
          ethers.utils.parseEther("1"),
          "0x"
        );
        
        const tx = await wallet.transactions(txIndex);
        expect(tx.to).to.equal(recipient.address);
        expect(tx.numConfirmations).to.equal(1);
      });
      
      it("Should execute transaction when threshold met", async () => {
        await wallet.connect(owner1).submitTransaction(
          recipient.address,
          ethers.utils.parseEther("1"),
          "0x"
        );
        
        await wallet.connect(owner2).confirmTransaction(0);
        
        const recipientBalance = await ethers.provider.getBalance(recipient.address);
        expect(recipientBalance).to.equal(ethers.utils.parseEther("1"));
      });
      
      it("Should prevent non-owner from confirming", async () => {
        await wallet.connect(owner1).submitTransaction(
          recipient.address,
          ethers.utils.parseEther("1"),
          "0x"
        );
        
        await expect(
          wallet.connect(recipient).confirmTransaction(0)
        ).to.be.revertedWith("Not an owner");
      });
      
      it("Should allow owner removal", async () => {
        await wallet.connect(owner1).removeOwner(owner3.address);
        
        const isOwner3 = await wallet.isOwner(owner3.address);
        expect(isOwner3).to.equal(false);
      });
    });
    

    总结

    多签钱包是区块链开发者的必修课题。本文从基础实现出发,逐步扩展到时间锁机制和DAO治理模块,展示了多签合约的核心设计模式。

    关键要点回顾:

    1. M-of-N机制:至少M个签名者确认才能执行,是多签的核心逻辑
    2. 事件驱动:所有状态变更必须发出事件,便于前端追踪和链下索引
    3. 修饰符复用:通过修饰符组合实现权限控制和状态验证
    4. 时间锁:为高风险操作引入延迟,提供安全保障和干预窗口
    5. 模块化:将功能分离到独立模块,便于扩展和维护

    在实际生产环境中,建议直接使用经过审计的Gnosis Safe合约代码,而不是从零实现。但理解其内部原理对于安全审计和定制开发至关重要。

    相关阅读

  • DApp前端数据管理最佳实践:状态管理与实时链上数据同步

    DApp前端数据管理最佳实践:状态管理与实时链上数据同步

    引言

    DApp开发与传统Web应用最大的区别在于数据的来源。传统应用从后端API获取数据,而DApp需要从区块链节点同步状态。这种数据获取方式的根本差异,使得状态管理成为DApp前端开发中最具挑战性的问题之一。

    本文将系统性地探讨DApp前端的状态管理策略,从基础的状态抽象到高级的缓存优化,帮助你构建响应迅速、用户体验优秀的去中心化应用。

    DApp状态管理方案对比图,Context+Hooks、Zustand、React Query三大方案与实时同步策略

    一、DApp状态管理的核心挑战

    1.1 数据获取的特性

    区块链数据的获取与传统API有本质区别:

    javascript

    // 传统API vs 区块链数据的对比
    
    // 传统API
    const response = await fetch('/api/user/balance');
    const balance = response.json(); // 瞬时返回
    
    // 区块链数据
    const balance = await contract.balanceOf(userAddress); 
    // 需要:
    // 1. 构造交易或调用
    // 2. 发送到节点
    // 3. 等待响应
    // 4. 解析返回数据
    // 可能需要数秒到数十秒
    

    1.2 需要管理的多种状态

    typescript

    // DApp中需要管理的各种状态类型
    
    // 1. 链上状态 - 存储在智能合约中
    interface OnChainState {
      // Token余额
      tokenBalance: bigint;
      // NFT所有权
      ownedNfts: string[];
      // 投票权重
      votingPower: bigint;
    }
    
    // 2. 交易状态 - pending/confirmed/failed
    interface TransactionState {
      status: 'idle' | 'pending' | 'confirming' | 'confirmed' | 'failed';
      hash?: string;
      confirmations: number;
      receipt?: TransactionReceipt;
      error?: Error;
    }
    
    // 3. 合约元数据 - ABI、地址等
    interface ContractMeta {
      address: Address;
      abi: ABI;
      chainId: number;
    }
    
    // 4. 用户界面状态
    interface UIState {
      selectedToken: Address | null;
      isModalOpen: boolean;
      activeTab: 'swap' | 'pool' | 'stats';
      theme: 'light' | 'dark';
    }
    
    // 5. Web3连接状态
    interface Web3State {
      address: Address | null;
      chainId: number | null;
      isConnecting: boolean;
      provider: BrowserProvider | null;
    }
    

    1.3 状态管理的目标

    typescript

    // 理想的状态管理应该满足:
    // 1. 单一数据源 - Single Source of Truth
    // 2. 可预测的状态变化 - Predictable Updates
    // 3. 高效的重新渲染 - Efficient Re-renders
    // 4. 持久化关键状态 - Persistence
    // 5. 错误恢复能力 - Error Recovery
    
    interface StateManagementGoals {
      singleSourceOfTruth: true;  // 所有状态从单一store管理
      predictableUpdates: true;    // 状态变化可追踪、可回滚
      efficientRenders: true;     // 只在需要时重新渲染
      persistence: true;           // 刷新页面不丢失关键状态
      errorRecovery: true;        // 错误后能恢复到正确状态
    }
    

    二、React状态管理方案

    2.1 Context + Hooks 基础方案

    typescript

    // 基础Web3 Context实现
    import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
    import { BrowserProvider, JsonRpcSigner } from 'ethers';
    
    // 定义Context类型
    interface Web3ContextType {
      address: Address | null;
      chainId: number | null;
      provider: BrowserProvider | null;
      signer: JsonRpcSigner | null;
      connect: () => Promise<void>;
      disconnect: () => void;
      isConnecting: boolean;
      error: Error | null;
    }
    
    // 创建Context
    const Web3Context = createContext<Web3ContextType | null>(null);
    
    // Provider组件
    export function Web3Provider({ children }: { children: ReactNode }) {
      const [address, setAddress] = useState<Address | null>(null);
      const [chainId, setChainId] = useState<number | null>(null);
      const [provider, setProvider] = useState<BrowserProvider | null>(null);
      const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
      const [isConnecting, setIsConnecting] = useState(false);
      const [error, setError] = useState<Error | null>(null);
    
      // 监听账户变化
      useEffect(() => {
        if (!provider) return;
    
        const handleAccountsChanged = (accounts: Address[]) => {
          if (accounts.length === 0) {
            disconnect();
          } else {
            setAddress(accounts[0]);
          }
        };
    
        const handleChainChanged = (chainIdHex: string) => {
          setChainId(parseInt(chainIdHex, 16));
          // 刷新页面以加载新链数据
          window.location.reload();
        };
    
        provider.on('accountsChanged', handleAccountsChanged);
        provider.on('chainChanged', handleChainChanged);
    
        return () => {
          provider.removeListener('accountsChanged', handleAccountsChanged);
          provider.removeListener('chainChanged', handleChainChanged);
        };
      }, [provider]);
    
      const connect = async () => {
        setIsConnecting(true);
        setError(null);
    
        try {
          // 检查MetaMask是否存在
          if (!window.ethereum) {
            throw new Error('Please install MetaMask to use this DApp');
          }
    
          // 请求账户连接
          const accounts = await window.ethereum.request({
            method: 'eth_requestAccounts'
          }) as Address[];
    
          const browserProvider = new BrowserProvider(window.ethereum);
          const browserSigner = await browserProvider.getSigner();
          const network = await browserProvider.getNetwork();
    
          setProvider(browserProvider);
          setSigner(browserSigner);
          setAddress(accounts[0]);
          setChainId(Number(network.chainId));
        } catch (err) {
          setError(err as Error);
          console.error('Failed to connect wallet:', err);
        } finally {
          setIsConnecting(false);
        }
      };
    
      const disconnect = () => {
        setAddress(null);
        setChainId(null);
        setProvider(null);
        setSigner(null);
      };
    
      return (
        <Web3Context.Provider 
          value={{ 
            address, 
            chainId, 
            provider, 
            signer, 
            connect, 
            disconnect, 
            isConnecting,
            error 
          }}
        >
          {children}
        </Web3Context.Provider>
      );
    }
    
    // 自定义Hook
    export function useWeb3() {
      const context = useContext(Web3Context);
      if (!context) {
        throw new Error('useWeb3 must be used within Web3Provider');
      }
      return context;
    }
    

    2.2 Zustand 状态管理

    Zustand是DApp开发中非常流行的状态管理库,它比Redux轻量且使用更简单:

    typescript

    // 安装:npm install zustand
    
    import { create } from 'zustand';
    import { persist, createJSONStorage } from 'zustand/middleware';
    import { BrowserProvider, JsonRpcSigner } from 'ethers';
    
    // ========== Web3 Store ==========
    interface Web3Store {
      // State
      address: Address | null;
      chainId: number | null;
      provider: BrowserProvider | null;
      signer: JsonRpcSigner | null;
      isConnecting: boolean;
      error: Error | null;
    
      // Actions
      setProvider: (provider: BrowserProvider | null) => void;
      setAddress: (address: Address | null) => void;
      setChainId: (chainId: number | null) => void;
      connect: () => Promise<void>;
      disconnect: () => void;
      reset: () => void;
    }
    
    export const useWeb3Store = create<Web3Store>()((set, get) => ({
      address: null,
      chainId: null,
      provider: null,
      signer: null,
      isConnecting: false,
      error: null,
    
      setProvider: (provider) => set({ provider }),
      setAddress: (address) => set({ address }),
      setChainId: (chainId) => set({ chainId }),
    
      connect: async () => {
        set({ isConnecting: true, error: null });
        
        try {
          if (!window.ethereum) {
            throw new Error('MetaMask not found');
          }
    
          const accounts = await window.ethereum.request({
            method: 'eth_requestAccounts'
          }) as Address[];
    
          const provider = new BrowserProvider(window.ethereum);
          const signer = await provider.getSigner();
          const network = await provider.getNetwork();
    
          set({
            provider,
            signer,
            address: accounts[0],
            chainId: Number(network.chainId),
            isConnecting: false
          });
        } catch (err) {
          set({ error: err as Error, isConnecting: false });
          throw err;
        }
      },
    
      disconnect: () => set({
        address: null,
        chainId: null,
        provider: null,
        signer: null,
        error: null
      }),
    
      reset: () => set({
        address: null,
        chainId: null,
        provider: null,
        signer: null,
        isConnecting: false,
        error: null
      })
    }));
    
    // ========== Token Balance Store ==========
    interface TokenBalance {
      balance: bigint;
      lastUpdated: number;
      isLoading: boolean;
      error: Error | null;
    }
    
    interface TokenStore {
      balances: Record<Address, TokenBalance>;
      
      fetchBalance: (tokenAddress: Address, account: Address) => Promise<void>;
      updateBalance: (tokenAddress: Address, balance: bigint) => void;
      clearBalances: () => void;
    }
    
    export const useTokenStore = create<TokenStore>((set, get) => ({
      balances: {},
    
      fetchBalance: async (tokenAddress, account) => {
        const tokenId = `${tokenAddress}-${account}`;
        
        set((state) => ({
          balances: {
            ...state.balances,
            [tokenId]: { 
              ...state.balances[tokenId],
              isLoading: true,
              error: null 
            }
          }
        }));
    
        try {
          // 实际项目中这里会调用合约
          const balance = await fetchTokenBalance(tokenAddress, account);
          
          set((state) => ({
            balances: {
              ...state.balances,
              [tokenId]: {
                balance,
                lastUpdated: Date.now(),
                isLoading: false,
                error: null
              }
            }
          }));
        } catch (err) {
          set((state) => ({
            balances: {
              ...state.balances,
              [tokenId]: {
                ...state.balances[tokenId],
                isLoading: false,
                error: err as Error
              }
            }
          }));
        }
      },
    
      updateBalance: (tokenAddress, balance) => {
        const tokenId = `${tokenAddress}-${get().balances}`;
        set((state) => ({
          balances: {
            ...state.balances,
            [tokenId]: {
              balance,
              lastUpdated: Date.now(),
              isLoading: false,
              error: null
            }
          }
        }));
      },
    
      clearBalances: () => set({ balances: {} })
    }));
    
    // ========== 带持久化的用户偏好Store ==========
    interface PreferencesStore {
      theme: 'light' | 'dark';
      language: 'en' | 'zh';
      slippageTolerance: number;
      transactionDeadline: number; // 分钟
      
      setTheme: (theme: 'light' | 'dark') => void;
      setLanguage: (language: 'en' | 'zh') => void;
      setSlippageTolerance: (tolerance: number) => void;
      setTransactionDeadline: (deadline: number) => void;
    }
    
    export const usePreferencesStore = create<PreferencesStore>()(
      persist(
        (set) => ({
          theme: 'dark',
          language: 'en',
          slippageTolerance: 0.5, // 0.5%
          transactionDeadline: 20, // 20分钟
    
          setTheme: (theme) => set({ theme }),
          setLanguage: (language) => set({ language }),
          setSlippageTolerance: (slippageTolerance) => set({ slippageTolerance }),
          setTransactionDeadline: (transactionDeadline) => set({ transactionDeadline })
        }),
        {
          name: 'dapp-preferences',
          storage: createJSONStorage(() => localStorage)
        }
      )
    );
    

    2.3 React Query + Web3 集成

    TanStack Query(原React Query)是管理异步数据的神器,与Web3数据完美契合:

    typescript

    import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
    import { useContract, useContractWrite } from 'wagmi';
    import { parseEther, formatEther } from 'viem';
    
    // 合约配置
    const USDC_CONTRACT = {
      address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
      abi: [
        {
          name: 'balanceOf',
          type: 'function',
          inputs: [{ name: 'account', type: 'address' }],
          outputs: [{ type: 'uint256' }]
        },
        {
          name: 'transfer',
          type: 'function',
          inputs: [
            { name: 'to', type: 'address' },
            { name: 'amount', type: 'uint256' }
          ],
          outputs: [{ type: 'bool' }]
        }
      ]
    } as const;
    
    // ========== 查询Hook ==========
    export function useTokenBalance(address: Address | undefined) {
      return useQuery({
        queryKey: ['token-balance', USDC_CONTRACT.address, address],
        queryFn: async () => {
          if (!address) return null;
          
          // 使用 wagmi 的 useContractRead
          const balance = await readContract({
            address: USDC_CONTRACT.address,
            abi: USDC_CONTRACT.abi,
            functionName: 'balanceOf',
            args: [address]
          });
          
          return {
            raw: balance as bigint,
            formatted: formatEther(balance as bigint),
            decimals: 6 // USDC decimals
          };
        },
        enabled: !!address,
        staleTime: 1000 * 30, // 30秒内认为数据新鲜
        refetchInterval: 1000 * 60 // 每分钟自动刷新
      });
    }
    
    // ========== 交易Hook ==========
    export function useTokenTransfer() {
      const queryClient = useQueryClient();
      
      return useMutation({
        mutationFn: async ({ to, amount }: { to: Address; amount: bigint }) => {
          const hash = await writeContract({
            address: USDC_CONTRACT.address,
            abi: USDC_CONTRACT.abi,
            functionName: 'transfer',
            args: [to, amount]
          });
          return hash;
        },
        onSuccess: (hash, variables) => {
          // 交易发送后,乐观更新UI
          queryClient.setQueryData(
            ['token-balance', USDC_CONTRACT.address, variables.to],
            (oldData: { raw: bigint } | undefined) => {
              if (!oldData) return oldData;
              return {
                ...oldData,
                raw: oldData.raw + variables.amount
              };
            }
          );
          
          // 或者使相关查询失效
          queryClient.invalidateQueries({
            queryKey: ['token-balance', USDC_CONTRACT.address]
          });
        }
      });
    }
    
    // ========== 组合使用 ==========
    function TransferForm() {
      const { address } = useAccount();
      const { data: balance, isLoading } = useTokenBalance(address);
      const transferMutation = useTokenTransfer();
      
      const handleTransfer = (to: Address, amount: string) => {
        const amountWei = parseEther(amount);
        transferMutation.mutate({ to, amount: amountWei });
      };
      
      if (isLoading) return <div>Loading...</div>;
      
      return (
        <div>
          <p>Balance: {balance?.formatted} USDC</p>
          <button 
            onClick={() => handleTransfer('0x...', '100')}
            disabled={transferMutation.isPending}
          >
            {transferMutation.isPending ? 'Transferring...' : 'Transfer'}
          </button>
        </div>
      );
    }
    

    三、链上数据实时同步

    3.1 事件监听架构

    typescript

    // 事件监听管理器
    class EventListenerManager {
      private listeners: Map<string, Set<Listener>> = new Map();
      private provider: BrowserProvider;
      private chainId: number;
      
      constructor(provider: BrowserProvider) {
        this.provider = provider;
        this.chainId = provider.getNetwork().then(n => Number(n.chainId));
      }
      
      subscribe<T>(
        contract: Contract,
        eventName: string,
        callback: (event: T) => void,
        filter?: Filter
      ) {
        const key = `${contract.address}-${eventName}`;
        
        const listener = contract.filters[eventName as keyof typeof contract.filters](
          ...(filter?.args || [])
        ) as Listener;
        
        const wrappedCallback = (args: T) => {
          try {
            callback(args);
          } catch (err) {
            console.error(`Event callback error for ${eventName}:`, err);
          }
        };
        
        contract.on(listener, wrappedCallback);
        
        if (!this.listeners.has(key)) {
          this.listeners.set(key, new Set());
        }
        this.listeners.get(key)!.add(wrappedCallback);
        
        // 返回取消订阅函数
        return () => {
          contract.off(listener, wrappedCallback);
          this.listeners.get(key)?.delete(wrappedCallback);
        };
      }
      
      // 批量取消订阅
      unsubscribeAll() {
        this.listeners.forEach((callbacks) => {
          callbacks.clear();
        });
        this.listeners.clear();
      }
    }
    
    // ========== React集成 ==========
    import { useEffect, useRef, useCallback } from 'react';
    
    export function useContractEvent(
      contract: Contract,
      eventName: string,
      callback: (...args: any[]) => void,
      dependencies: any[] = []
    ) {
      const callbackRef = useRef(callback);
      callbackRef.current = callback;
      
      useEffect(() => {
        const listener = (...args: any[]) => {
          callbackRef.current(...args);
        };
        
        // @ts-ignore
        contract.on(eventName, listener);
        
        return () => {
          // @ts-ignore
          contract.off(eventName, listener);
        };
      }, [contract, eventName, ...dependencies]);
    }
    
    // ========== Transfer事件监听示例 ==========
    function TransferListener({ address }: { address: Address }) {
      const [transfers, setTransfers] = useState<Transfer[]>([]);
      
      // 假设 tokenContract 已定义
      const tokenContract = useTokenContract();
      
      useContractEvent(
        tokenContract,
        'Transfer',
        (from: Address, to: Address, value: bigint) => {
          // 只关心涉及当前用户的事件
          if (from === address || to === address) {
            setTransfers((prev) => [
              { from, to, value, timestamp: Date.now() },
              ...prev.slice(0, 99) // 保留最近100条
            ]);
          }
        },
        [address]
      );
      
      return (
        <div>
          <h3>Recent Transfers</h3>
          <ul>
            {transfers.map((t, i) => (
              <li key={i}>
                {t.from === address ? 'Sent' : 'Received'} {formatEther(t.value)}
              </li>
            ))}
          </ul>
        </div>
      );
    }
    

    3.2 区块头监听

    typescript

    // 实时区块更新Hook
    export function useNewBlock(callback: (blockNumber: number) => void) {
      const { provider } = useWeb3Store();
      
      useEffect(() => {
        if (!provider) return;
        
        const handleNewBlock = (blockNumber: number) => {
          callback(blockNumber);
        };
        
        provider.on('block', handleNewBlock);
        
        return () => {
          provider.off('block', handleNewBlock);
        };
      }, [provider, callback]);
    }
    
    // ========== 实时余额更新 ==========
    function useLiveTokenBalance(tokenAddress: Address, account: Address | undefined) {
      const [balance, setBalance] = useState<bigint | null>(null);
      const [isLoading, setIsLoading] = useState(true);
      
      // 初始获取
      useEffect(() => {
        if (!account) {
          setBalance(null);
          setIsLoading(false);
          return;
        }
        
        const fetchBalance = async () => {
          setIsLoading(true);
          try {
            const balance = await getTokenBalance(tokenAddress, account);
            setBalance(balance);
          } catch (err) {
            console.error('Failed to fetch balance:', err);
          } finally {
            setIsLoading(false);
          }
        };
        
        fetchBalance();
      }, [tokenAddress, account]);
      
      // 新区块时刷新
      useNewBlock(async () => {
        if (!account) return;
        
        try {
          const newBalance = await getTokenBalance(tokenAddress, account);
          setBalance(newBalance);
        } catch (err) {
          console.error('Failed to refresh balance:', err);
        }
      });
      
      return { balance, isLoading };
    }
    
    // ========== 确认数更新 ==========
    export function useTransactionConfirmations(txHash: Address | undefined) {
      const [confirmations, setConfirmations] = useState(0);
      const [isConfirmed, setIsConfirmed] = useState(false);
      const requiredConfirmations = 12;
      
      const { provider } = useWeb3Store();
      
      useEffect(() => {
        if (!txHash || !provider) return;
        
        const checkConfirmations = async () => {
          try {
            const receipt = await provider.getTransactionReceipt(txHash);
            if (!receipt) return;
            
            const currentBlock = await provider.getBlockNumber();
            const blockDiff = currentBlock - receipt.blockNumber;
            
            setConfirmations(blockDiff);
            setIsConfirmed(blockDiff >= requiredConfirmations);
          } catch (err) {
            console.error('Failed to check confirmations:', err);
          }
        };
        
        // 立即检查
        checkConfirmations();
        
        // 监听新区块
        provider.on('block', checkConfirmations);
        
        return () => {
          provider.off('block', checkConfirmations);
        };
      }, [txHash, provider]);
      
      return { confirmations, isConfirmed };
    }
    

    3.3 WebSocket实时数据

    typescript

    // Alchemy WebSocket Provider配置
    import { AlchemyProvider, WebSocketProvider } from 'ethers';
    
    const alchemyApiKey = process.env.ALCHEMY_API_KEY;
    const wsProvider = new WebSocketProvider(
      `wss://eth-mainnet.g.alchemy.com/v2/${alchemyApiKey}`
    );
    
    // ========== 实时价格订阅 ==========
    export function useEthPrice() {
      const [price, setPrice] = useState<number | null>(null);
      const [priceChange, setPriceChange] = useState<number>(0);
      
      useEffect(() => {
        let isMounted = true;
        
        const fetchInitialPrice = async () => {
          try {
            // 使用Alchemy Price Oracle
            const response = await fetch(
              `https://api.etherscan.io/api?module=stats&action=ethprice`
            );
            const data = await response.json();
            if (isMounted && data.status === '1') {
              setPrice(parseFloat(data.result.ethusd));
            }
          } catch (err) {
            console.error('Failed to fetch ETH price:', err);
          }
        };
        
        // WebSocket订阅价格更新
        const subscribeToPrice = async () => {
          wsProvider.on('block', async () => {
            try {
              const response = await fetch(
                `https://api.etherscan.io/api?module=stats&action=ethprice`
              );
              const data = await response.json();
              if (isMounted && data.status === '1') {
                const newPrice = parseFloat(data.result.ethusd);
                setPriceChange(prev => newPrice - price!);
                setPrice(newPrice);
              }
            } catch (err) {
              console.error('Failed to update ETH price:', err);
            }
          });
        };
        
        fetchInitialPrice();
        subscribeToPrice();
        
        return () => {
          isMounted = false;
          wsProvider.removeAllListeners('block');
        };
      }, []);
      
      return { price, priceChange };
    }
    
    // ========== 简化版:使用ethers的监听功能 ==========
    export function useBlockNumber() {
      const [blockNumber, setBlockNumber] = useState<number>(0);
      
      const { provider } = useWeb3Store();
      
      useEffect(() => {
        if (!provider) return;
        
        const fetchCurrentBlock = async () => {
          const block = await provider.getBlockNumber();
          setBlockNumber(block);
        };
        
        fetchCurrentBlock();
        
        provider.on('block', setBlockNumber);
        
        return () => {
          provider.off('block', setBlockNumber);
        };
      }, [provider]);
      
      return blockNumber;
    }
    

    四、数据缓存与优化

    4.1 多层缓存策略

    typescript

    // 缓存策略配置
    interface CacheConfig {
      // 缓存有效期(毫秒)
      ttl: number;
      // 最大缓存条目数
      maxSize: number;
      // 缓存Key前缀
      prefix: string;
    }
    
    // 缓存条目
    interface CacheEntry<T> {
      data: T;
      timestamp: number;
      hits: number;
    }
    
    // 简单内存缓存实现
    class Web3Cache {
      private cache: Map<string, CacheEntry<any>> = new Map();
      private config: CacheConfig;
      
      constructor(config: CacheConfig) {
        this.config = config;
      }
      
      get<T>(key: string): T | null {
        const entry = this.cache.get(key);
        
        if (!entry) return null;
        
        // 检查是否过期
        if (Date.now() - entry.timestamp > this.config.ttl) {
          this.cache.delete(key);
          return null;
        }
        
        // 更新命中计数
        entry.hits++;
        return entry.data as T;
      }
      
      set<T>(key: string, data: T): void {
        // 检查缓存大小
        if (this.cache.size >= this.config.maxSize) {
          this.evictLeastUsed();
        }
        
        this.cache.set(key, {
          data,
          timestamp: Date.now(),
          hits: 0
        });
      }
      
      private evictLeastUsed(): void {
        let minHits = Infinity;
        let minKey: string | null = null;
        
        for (const [key, entry] of this.cache) {
          if (entry.hits < minHits) {
            minHits = entry.hits;
            minKey = key;
          }
        }
        
        if (minKey) {
          this.cache.delete(minKey);
        }
      }
      
      clear(): void {
        this.cache.clear();
      }
    }
    
    // 创建缓存实例
    const web3Cache = new Web3Cache({
      ttl: 1000 * 60, // 1分钟
      maxSize: 100,
      prefix: 'web3'
    });
    
    // ========== 带缓存的数据获取 ==========
    export async function getCachedTokenBalance(
      tokenAddress: Address,
      account: Address
    ): Promise<bigint> {
      const cacheKey = `balance-${tokenAddress}-${account}`;
      
      // 先检查缓存
      const cached = web3Cache.get<bigint>(cacheKey);
      if (cached !== null) {
        console.log('Using cached balance');
        return cached;
      }
      
      // 缓存未命中,从链上获取
      const balance = await fetchTokenBalance(tokenAddress, account);
      
      // 更新缓存
      web3Cache.set(cacheKey, balance);
      
      return balance;
    }
    

    4.2 乐观更新模式

    typescript

    // 乐观更新Hook
    export function useOptimisticUpdate<T>() {
      const [optimisticData, setOptimisticData] = useState<T | null>(null);
      const [isOptimistic, setIsOptimistic] = useState(false);
      
      const applyOptimisticUpdate = useCallback((
        updater: (current: T | null) => T,
        onConfirm: (newData: T) => void,
        onRollback: () => void
      ) => {
        // 应用乐观更新
        setOptimisticData(prev => updater(prev));
        setIsOptimistic(true);
        
        // 返回确认/回滚函数
        return {
          confirm: (confirmedData: T) => {
            setOptimisticData(confirmedData);
            setIsOptimistic(false);
            onConfirm(confirmedData);
          },
          rollback: () => {
            setOptimisticData(null);
            setIsOptimistic(false);
            onRollback();
          }
        };
      }, []);
      
      return { optimisticData, isOptimistic, applyOptimisticUpdate };
    }
    
    // ========== 乐观更新示例:Like功能 ==========
    function useLikes(postId: string) {
      const [likes, setLikes] = useState<Set<Address>>(new Set());
      const { address } = useWeb3Store();
      
      // 初始加载
      useEffect(() => {
        fetchLikes(postId).then(setLikes);
      }, [postId]);
      
      const toggleLike = async () => {
        if (!address) return;
        
        const isLiked = likes.has(address);
        const newLikes = new Set(likes);
        
        // 乐观更新
        if (isLiked) {
          newLikes.delete(address);
        } else {
          newLikes.add(address);
        }
        setLikes(newLikes);
        
        try {
          // 发送交易
          await sendLikeTransaction(postId);
          
          // 交易确认后,可能需要从链上重新同步
          // 但对于Like这种场景,乐观更新通常已经足够
        } catch (err) {
          // 失败,回滚
          setLikes(likes);
          console.error('Failed to toggle like:', err);
        }
      };
      
      return { likes, toggleLike, isLiked: likes.has(address) };
    }
    
    // ========== 交易历史乐观更新 ==========
    function useTransactionHistory() {
      const [transactions, setTransactions] = useState<Transaction[]>([]);
      const { address } = useWeb3Store();
      const queryClient = useQueryClient();
      
      // 加载历史交易
      const { data: history = [] } = useQuery({
        queryKey: ['tx-history', address],
        queryFn: () => fetchTransactionHistory(address),
        enabled: !!address
      });
      
      useEffect(() => {
        setTransactions(history);
      }, [history]);
      
      const sendTransaction = async (tx: PendingTransaction) => {
        // 创建临时交易
        const tempTx: Transaction = {
          ...tx,
          id: `temp-${Date.now()}`,
          status: 'pending',
          hash: null
        };
        
        // 乐观添加到列表开头
        setTransactions(prev => [tempTx, ...prev]);
        
        try {
          // 发送交易
          const hash = await executeTransaction(tx);
          
          // 更新临时交易
          setTransactions(prev => 
            prev.map(t => 
              t.id === tempTx.id 
                ? { ...t, hash, status: 'pending' }
                : t
            )
          );
          
          // 等待确认
          const receipt = await waitForTransaction(hash);
          
          // 更新最终状态
          setTransactions(prev => 
            prev.map(t => 
              t.hash === hash 
                ? { ...t, status: 'confirmed', receipt }
                : t
            )
          );
        } catch (err) {
          // 失败,标记为失败
          setTransactions(prev => 
            prev.map(t => 
              t.id === tempTx.id 
                ? { ...t, status: 'failed', error: err as Error }
                : t
            )
          );
          
          // 一定时间后从列表移除失败交易
          setTimeout(() => {
            setTransactions(prev => prev.filter(t => t.id !== tempTx.id));
          }, 5000);
        }
      };
      
      return { transactions, sendTransaction };
    }
    

    4.3 数据分页与虚拟化

    typescript

    // ========== 分页加载示例 ==========
    interface PaginatedResult<T> {
      items: T[];
      total: number;
      hasMore: boolean;
      nextCursor?: string;
    }
    
    async function fetchNFTs(
      address: Address,
      cursor?: string,
      pageSize: number = 20
    ): Promise<PaginatedResult<NFT>> {
      // 使用Alchemy NFT API
      const response = await fetch(
        `https://eth-mainnet.g.alchemy.com/nft/v3/${ALCHEMY_KEY}/getNFTsForOwner`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            owner: address,
            pageKey: cursor,
            pageSize,
            withMetadata: true
          })
        }
      );
      
      const data = await response.json();
      
      return {
        items: data.ownedNfts,
        total: data.totalCount,
        hasMore: !!data.pageKey,
        nextCursor: data.pageKey
      };
    }
    
    // ========== 无限滚动Hook ==========
    export function useInfiniteNFTs(address: Address | undefined) {
      const [nfts, setNfts] = useState<NFT[]>([]);
      const [cursor, setCursor] = useState<string | undefined>();
      const [hasMore, setHasMore] = useState(true);
      const [isLoading, setIsLoading] = useState(false);
      const observerRef = useRef<IntersectionObserver>();
      
      const loadMore = useCallback(async () => {
        if (!address || isLoading || !hasMore) return;
        
        setIsLoading(true);
        
        try {
          const result = await fetchNFTs(address, cursor);
          setNfts(prev => [...prev, ...result.items]);
          setCursor(result.nextCursor);
          setHasMore(result.hasMore);
        } catch (err) {
          console.error('Failed to load NFTs:', err);
        } finally {
          setIsLoading(false);
        }
      }, [address, cursor, hasMore, isLoading]);
      
      // 初始加载
      useEffect(() => {
        if (address) {
          setNfts([]);
          setCursor(undefined);
          setHasMore(true);
          loadMore();
        }
      }, [address]);
      
      // 无限滚动触发器
      const loadMoreRef = useCallback((node: HTMLElement | null) => {
        if (observerRef.current) {
          observerRef.current.disconnect();
        }
        
        if (!node) return;
        
        observerRef.current = new IntersectionObserver(entries => {
          if (entries[0].isIntersecting && hasMore && !isLoading) {
            loadMore();
          }
        });
        
        observerRef.current.observe(node);
      }, [loadMore, hasMore, isLoading]);
      
      return { nfts, loadMoreRef, isLoading, hasMore };
    }
    
    // ========== 虚拟化长列表 ==========
    import { useVirtualizer } from '@tanstack/react-virtual';
    
    function NFTGallery({ nfts }: { nfts: NFT[] }) {
      const parentRef = useRef<HTMLDivElement>(null);
      
      const virtualizer = useVirtualizer({
        count: nfts.length,
        getScrollElement: () => parentRef.current,
        estimateSize: () => 300,
        overscan: 5
      });
      
      return (
        <div 
          ref={parentRef}
          style={{ height: '600px', overflow: 'auto' }}
        >
          <div
            style={{
              height: `${virtualizer.getTotalSize()}px`,
              position: 'relative'
            }}
          >
            {virtualizer.getVirtualItems().map((virtualItem) => (
              <div
                key={virtualItem.key}
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: '100%',
                  height: `${virtualItem.size}px`,
                  transform: `translateY(${virtualItem.start}px)`
                }}
              >
                <NFTCard nft={nfts[virtualItem.index]} />
              </div>
            ))}
          </div>
        </div>
      );
    }
    

    五、完整应用示例

    5.1 Web3状态管理架构

    typescript

    // ========== 完整的状态管理架构 ==========
    
    // stores/web3Store.ts
    import { create } from 'zustand';
    import { persist, createJSONStorage } from 'zustand/middleware';
    
    // 合约Store
    interface ContractStore {
      addresses: Record<string, Address>;
      abis: Record<string, ABI>;
      
      setContractAddress: (name: string, address: Address) => void;
      getContract: (name: string) => ContractConfig | null;
    }
    
    export const useContractStore = create<ContractStore>()(
      persist(
        (set, get) => ({
          addresses: {},
          abis: {},
          
          setContractAddress: (name, address) => 
            set(state => ({
              addresses: { ...state.addresses, [name]: address }
            })),
          
          getContract: (name) => {
            const { addresses, abis } = get();
            const address = addresses[name];
            const abi = abis[name];
            
            if (!address || !abi) return null;
            return { address, abi };
          }
        }),
        {
          name: 'contracts-config',
          storage: createJSONStorage(() => localStorage)
        }
      )
    );
    
    // 交易Store
    interface TransactionStore {
      pendingTxs: PendingTransaction[];
      
      addPendingTx: (tx: PendingTransaction) => void;
      updateTx: (id: string, updates: Partial<PendingTransaction>) => void;
      removeTx: (id: string) => void;
      clearAll: () => void;
    }
    
    interface PendingTransaction {
      id: string;
      type: string;
      status: 'pending' | 'confirming' | 'confirmed' | 'failed';
      hash?: Address;
      confirmations: number;
      timestamp: number;
      description: string;
      params?: Record<string, any>;
    }
    
    export const useTransactionStore = create<TransactionStore>((set) => ({
      pendingTxs: [],
      
      addPendingTx: (tx) => 
        set(state => ({
          pendingTxs: [tx, ...state.pendingTxs].slice(0, 50) // 最多保留50条
        })),
      
      updateTx: (id, updates) =>
        set(state => ({
          pendingTxs: state.pendingTxs.map(tx =>
            tx.id === id ? { ...tx, ...updates } : tx
          )
        })),
      
      removeTx: (id) =>
        set(state => ({
          pendingTxs: state.pendingTxs.filter(tx => tx.id !== id)
        })),
      
      clearAll: () => set({ pendingTxs: [] })
    }));
    

    5.2 应用入口整合

    typescript

    // App.tsx
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
    import { WagmiProvider, createConfig, http } from 'wagmi';
    import { mainnet, polygon, arbitrum } from 'wagmi/chains';
    import { MetaMaskConnector } from 'wagmi/connectors/metaMask';
    import { Web3Provider } from './providers/Web3Provider';
    import { TransactionProvider } from './providers/TransactionProvider';
    
    // Wagmi配置
    const wagmiConfig = createConfig({
      chains: [mainnet, polygon, arbitrum],
      connectors: [
        new MetaMaskConnector()
      ],
      transports: {
        [mainnet.id]: http(),
        [polygon.id]: http(),
        [arbitrum.id]: http()
      }
    });
    
    // React Query配置
    const queryClient = new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 1000 * 30, // 30秒
          gcTime: 1000 * 60 * 5, // 5分钟
          retry: 2,
          refetchOnWindowFocus: false
        }
      }
    });
    
    function App() {
      return (
        <WagmiProvider config={wagmiConfig}>
          <QueryClientProvider client={queryClient}>
            <Web3Provider>
              <TransactionProvider>
                <MainLayout>
                  <Routes>
                    <Route path="/" element={<Dashboard />} />
                    <Route path="/swap" element={<Swap />} />
                    <Route path="/pool" element={<Pool />} />
                    <Route path="/nft" element={<NFTGallery />} />
                  </Routes>
                </MainLayout>
              </TransactionProvider>
            </Web3Provider>
          </QueryClientProvider>
        </WagmiProvider>
      );
    }
    

    总结

    DApp前端的状态管理是一个复杂的系统工程,需要综合考虑多个维度:

    维度关键点推荐方案
    Web3连接钱包状态、链切换Zustand + Context
    链上数据实时同步、缓存React Query + 事件监听
    交易状态pending/确认/失败独立Transaction Store
    UI状态主题、模态框Zustand (持久化)
    性能虚拟化、乐观更新TanStack Virtual

    核心原则

    1. 分层管理 – Web3状态、业务状态、UI状态分而治之
    2. 实时感知 – 通过事件监听保持链上数据同步
    3. 智能缓存 – 多层缓存策略减少不必要的链上查询
    4. 乐观更新 – 提升用户体验,交易即反馈
    5. 错误恢复 – 完善的错误处理和状态回滚机制

    相关推荐

  • DApp用户认证与会话管理最佳实践:从钱包连接到身份验证

    DApp用户认证与会话管理最佳实践:从钱包连接到身份验证

    引言

    用户认证是任何应用的基础功能,但在去中心化应用(DApp)世界中,传统的用户名密码认证模式并不适用。DApp依赖区块链钱包作为身份标识,用户通过签名消息来证明身份。这种认证方式带来了新的安全考量,也提出了独特的用户体验挑战。

    本文将系统讲解DApp用户认证与会话管理的完整方案,从基础的钱包连接,到高级的多因素认证和会话管理,帮助你构建既安全又流畅的用户认证体验。

    Web3认证流程图,展示挑战签名机制与会话令牌管理全链路

    一、DApp认证基础概念

    1.1 区块链身份与钱包

    在DApp中,用户的区块链地址就是其身份标识。与传统应用的账户体系相比,区块链身份有以下特点:

    • 无需注册:用户无需填写表单注册,连接钱包即完成”注册”
    • 自主托管:用户完全控制自己的私钥和应用访问权限
    • 密码学验证:通过签名消息验证身份,无需服务器存储密码

    typescript

    // 连接钱包获取用户地址
    import { ethers } from 'ethers';
    
    async function connectWallet(): Promise<string> {
      // 检查浏览器是否有钱包扩展
      if (typeof window.ethereum !== 'undefined') {
        try {
          // 请求用户授权连接
          const accounts = await window.ethereum.request({
            method: 'eth_requestAccounts'
          });
          
          if (accounts.length > 0) {
            const address = accounts[0];
            console.log('Connected wallet:', address);
            return address;
          }
        } catch (error) {
          console.error('Connection rejected:', error);
          throw new Error('User rejected connection');
        }
      } else {
        throw new Error('No wallet detected');
      }
    }
    
    // 获取当前已连接的账户
    async function getCurrentAccount(): Promise<string | null> {
      if (typeof window.ethereum !== 'undefined') {
        const accounts = await window.ethereum.request({
          method: 'eth_accounts'
        });
        return accounts.length > 0 ? accounts[0] : null;
      }
      return null;
    }
    

    1.2 Web3认证流程

    标准的Web3认证流程包括以下步骤:

    1. 连接请求:前端请求用户连接钱包
    2. 签名验证:前端生成随机挑战,用户签名
    3. 服务端验证:服务端验证签名,恢复用户地址
    4. 会话创建:验证通过后创建用户会话

    plaintext

    ┌─────────────┐     ┌──────────────┐     ┌─────────────┐
    │   前端      │     │   钱包       │     │   服务端    │
    └──────┬──────┘     └──────┬───────┘     └──────┬──────┘
           │                    │                    │
           │  1. 请求连接       │                    │
           │──────────────────>│                    │
           │                    │                    │
           │  2. 签名挑战       │                    │
           │<──────────────────│                    │
           │                    │                    │
           │  3. 签名结果       │                    │
           │──────────────────>│                    │
           │                    │                    │
           │                    │  4. 转发签名       │
           │                    │──────────────────>│
           │                    │                    │
           │                    │  5. 验证并返回JWT  │
           │                    │<──────────────────│
           │                    │                    │
           │  6. 返回认证令牌   │                    │
           │<────────────────────────────────────────│
    

    二、签名消息认证实现

    2.1 服务端挑战生成

    服务端需要生成唯一的挑战值,防止重放攻击:

    typescript

    // 服务端:生成和管理认证挑战
    import { randomBytes, createHash } from 'crypto';
    
    interface AuthChallenge {
      challenge: string;
      expiresAt: Date;
      nonce: string;
    }
    
    // 内存存储,实际生产应使用Redis
    const pendingChallenges = new Map<string, AuthChallenge>();
    
    export class AuthChallengeService {
      // 生成新的挑战
      generateChallenge(address: string): AuthChallenge {
        // 生成随机挑战
        const challenge = this.createChallengeString();
        const nonce = randomBytes(32).toString('hex');
        const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5分钟有效期
        
        const authChallenge: AuthChallenge = {
          challenge,
          expiresAt,
          nonce
        };
        
        // 存储挑战,关联到用户地址
        pendingChallenges.set(address.toLowerCase(), authChallenge);
        
        return authChallenge;
      }
      
      // 创建标准格式的挑战消息
      private createChallengeString(): string {
        const domain = 'your-dapp-domain.com';
        const uri = 'https://your-dapp-domain.com/auth';
        const version = '1';
        const chainId = 1; // Ethereum mainnet
        
        return `Sign this message to authenticate with ${domain}.
    
    This request will not trigger a blockchain transaction or cost any gas fees.
    
    Wallet address:
    [Your wallet address]
    
    Nonce: ${randomBytes(16).toString('hex')}
    
    Version: ${version}
    Chain ID: ${chainId}
    Issued At: ${new Date().toISOString()}
    Expiry: ${new Date(Date.now() + 5 * 60 * 1000).toISOString()}`;
      }
      
      // 验证并消费挑战
      async consumeChallenge(
        address: string, 
        signature: string
      ): Promise<{ valid: boolean; error?: string }> {
        const normalizedAddress = address.toLowerCase();
        const challenge = pendingChallenges.get(normalizedAddress);
        
        if (!challenge) {
          return { valid: false, error: 'No pending challenge' };
        }
        
        // 检查过期
        if (new Date() > challenge.expiresAt) {
          pendingChallenges.delete(normalizedAddress);
          return { valid: false, error: 'Challenge expired' };
        }
        
        // 验证签名
        const isValid = await this.verifySignature(
          address,
          challenge.challenge,
          signature
        );
        
        if (isValid) {
          // 消费后删除挑战(防止重放)
          pendingChallenges.delete(normalizedAddress);
          return { valid: true };
        }
        
        return { valid: false, error: 'Invalid signature' };
      }
      
      // 验证ECDSA签名
      private async verifySignature(
        address: string,
        message: string,
        signature: string
      ): Promise<boolean> {
        // 使用ethers.js验证签名
        const recoveredAddress = ethers.utils.verifyMessage(message, signature);
        return recoveredAddress.toLowerCase() === address.toLowerCase();
      }
    }
    

    2.2 前端签名流程

    typescript

    // 前端:发起签名认证请求
    import { ethers } from 'ethers';
    
    interface AuthResponse {
      success: boolean;
      token?: string;
      error?: string;
    }
    
    interface Challenge {
      challenge: string;
      expiresAt: string;
    }
    
    export class Web3AuthService {
      private provider: ethers.providers.Web3Provider | null = null;
      
      // 初始化Provider
      async initializeProvider(): Promise<void> {
        if (typeof window.ethereum !== 'undefined') {
          this.provider = new ethers.providers.Web3Provider(window.ethereum);
        } else {
          throw new Error('No wallet detected');
        }
      }
      
      // 获取挑战
      async requestChallenge(address: string): Promise<Challenge> {
        const response = await fetch('/api/auth/challenge', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ address })
        });
        
        if (!response.ok) {
          throw new Error('Failed to get challenge');
        }
        
        return response.json();
      }
      
      // 执行签名认证
      async authenticate(): Promise<AuthResponse> {
        if (!this.provider) {
          await this.initializeProvider();
        }
        
        try {
          // 1. 获取签名者地址
          const signer = this.provider!.getSigner();
          const address = await signer.getAddress();
          
          // 2. 请求服务端获取挑战
          const challenge = await this.requestChallenge(address);
          
          // 3. 用户签名挑战
          const signature = await signer.signMessage(challenge.challenge);
          
          // 4. 发送签名到服务端验证
          const verifyResponse = await fetch('/api/auth/verify', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              address,
              signature,
              challenge: challenge.challenge
            })
          });
          
          const result = await verifyResponse.json();
          
          if (result.success) {
            // 存储认证令牌
            this.storeAuthToken(result.token);
            return { success: true, token: result.token };
          }
          
          return { success: false, error: result.error };
          
        } catch (error) {
          console.error('Authentication failed:', error);
          return { 
            success: false, 
            error: error instanceof Error ? error.message : 'Unknown error' 
          };
        }
      }
      
      // 存储认证令牌
      private storeAuthToken(token: string): void {
        // 使用HttpOnly cookie更安全,这里简化处理
        localStorage.setItem('auth_token', token);
      }
    }
    

    三、会话管理方案

    3.1 JWT会话令牌

    认证通过后,使用JWT管理用户会话:

    typescript

    // JWT生成与验证
    import jwt from 'jsonwebtoken';
    import { createHmac } from 'crypto';
    
    interface SessionPayload {
      address: string;
      issuedAt: number;
      expiresAt: number;
      sessionId: string;
    }
    
    class SessionManager {
      private readonly secret: string;
      private readonly issuer = 'your-dapp';
      
      constructor() {
        // 生产环境从环境变量获取
        this.secret = process.env.JWT_SECRET || 'your-secret-key';
      }
      
      // 生成JWT
      generateToken(address: string): string {
        const now = Math.floor(Date.now() / 1000);
        
        const payload: SessionPayload = {
          address: address.toLowerCase(),
          issuedAt: now,
          expiresAt: now + 24 * 60 * 60, // 24小时
          sessionId: this.generateSessionId()
        };
        
        return jwt.sign(payload, this.secret, {
          algorithm: 'HS256',
          issuer: this.issuer
        });
      }
      
      // 验证JWT
      verifyToken(token: string): { valid: boolean; payload?: SessionPayload; error?: string } {
        try {
          const payload = jwt.verify(token, this.secret, {
            algorithms: ['HS256'],
            issuer: this.issuer
          }) as SessionPayload;
          
          // 检查是否过期
          const now = Math.floor(Date.now() / 1000);
          if (payload.expiresAt < now) {
            return { valid: false, error: 'Token expired' };
          }
          
          return { valid: true, payload };
          
        } catch (error) {
          return { 
            valid: false, 
            error: error instanceof Error ? error.message : 'Invalid token' 
          };
        }
      }
      
      // 刷新令牌
      refreshToken(oldToken: string): { success: boolean; token?: string; error?: string } {
        const verifyResult = this.verifyToken(oldToken);
        
        if (!verifyResult.valid || !verifyResult.payload) {
          return { success: false, error: verifyResult.error };
        }
        
        // 检查是否在刷新窗口内(过期前2小时内)
        const now = Math.floor(Date.now() / 1000);
        const refreshWindow = verifyResult.payload.expiresAt - 2 * 60 * 60;
        
        if (now < refreshWindow) {
          return { success: false, error: 'Too early to refresh' };
        }
        
        // 生成新令牌
        const newToken = this.generateToken(verifyResult.payload.address);
        return { success: true, token: newToken };
      }
      
      // 撤销令牌
      async revokeToken(token: string): Promise<void> {
        // 将token加入黑名单
        const { payload } = this.verifyToken(token);
        if (payload) {
          await this.addToBlacklist(payload.sessionId, payload.expiresAt);
        }
      }
      
      // 生成会话ID
      private generateSessionId(): string {
        return createHmac('sha256', this.secret)
          .update(Math.random().toString())
          .digest('hex');
      }
      
      // 黑名单管理(使用Redis)
      private async addToBlacklist(sessionId: string, expiresAt: number): Promise<void> {
        // TTL设为token的剩余有效期
        const ttl = expiresAt - Math.floor(Date.now() / 1000);
        // redis.setex(`blacklist:${sessionId}`, ttl, 'revoked');
        console.log(`Blacklisting session ${sessionId} for ${ttl}s`);
      }
    }
    

    3.2 安全的会话存储

    typescript

    // 前端会话管理
    class SessionStorage {
      private readonly TOKEN_KEY = 'web3_session';
      private readonly ADDRESS_KEY = 'web3_address';
      private refreshTimer: NodeJS.Timeout | null = null;
      
      // 保存会话
      saveSession(token: string, address: string): void {
        const session = {
          token,
          address,
          createdAt: Date.now()
        };
        
        localStorage.setItem(this.TOKEN_KEY, JSON.stringify(session));
        localStorage.setItem(this.ADDRESS_KEY, address);
        
        // 设置自动刷新
        this.scheduleRefresh(token);
      }
      
      // 获取当前会话
      getSession(): { token: string; address: string } | null {
        const stored = localStorage.getItem(this.TOKEN_KEY);
        if (!stored) return null;
        
        try {
          const session = JSON.parse(stored);
          return {
            token: session.token,
            address: session.address
          };
        } catch {
          this.clearSession();
          return null;
        }
      }
      
      // 检查会话是否有效
      async isSessionValid(): Promise<boolean> {
        const session = this.getSession();
        if (!session) return false;
        
        try {
          const response = await fetch('/api/auth/validate', {
            headers: {
              'Authorization': `Bearer ${session.token}`
            }
          });
          
          return response.ok;
        } catch {
          return false;
        }
      }
      
      // 清除会话
      clearSession(): void {
        localStorage.removeItem(this.TOKEN_KEY);
        localStorage.removeItem(this.ADDRESS_KEY);
        
        if (this.refreshTimer) {
          clearTimeout(this.refreshTimer);
          this.refreshTimer = null;
        }
      }
      
      // 定期刷新令牌
      private scheduleRefresh(token: string): void {
        // 在过期前1小时刷新
        const refreshTime = 60 * 60 * 1000; // 1小时
        const tokenData = this.parseJwt(token);
        
        if (tokenData && tokenData.exp) {
          const expiresIn = tokenData.exp * 1000 - Date.now();
          const timeUntilRefresh = expiresIn - refreshTime;
          
          if (timeUntilRefresh > 0) {
            this.refreshTimer = setTimeout(
              () => this.refreshSession(),
              timeUntilRefresh
            );
          }
        }
      }
      
      // 刷新会话
      private async refreshSession(): Promise<void> {
        const session = this.getSession();
        if (!session) return;
        
        try {
          const response = await fetch('/api/auth/refresh', {
            method: 'POST',
            headers: {
              'Authorization': `Bearer ${session.token}`
            }
          });
          
          if (response.ok) {
            const { token } = await response.json();
            this.saveSession(token, session.address);
          } else {
            this.clearSession();
            // 触发重新认证
            window.dispatchEvent(new CustomEvent('auth:required'));
          }
        } catch {
          this.clearSession();
        }
      }
      
      // 解析JWT(不验证)
      private parseJwt(token: string): any {
        try {
          const base64 = token.split('.')[1];
          return JSON.parse(atob(base64));
        } catch {
          return null;
        }
      }
    }
    

    四、React中的认证实现

    4.1 认证上下文

    tsx

    // React认证上下文
    import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
    import { ethers } from 'ethers';
    
    interface User {
      address: string;
      isConnected: boolean;
      isAuthenticated: boolean;
    }
    
    interface AuthContextValue {
      user: User | null;
      isLoading: boolean;
      error: string | null;
      connect: () => Promise<void>;
      disconnect: () => void;
      authenticate: () => Promise<boolean>;
    }
    
    const AuthContext = createContext<AuthContextValue | null>(null);
    
    export function AuthProvider({ children }: { children: React.ReactNode }) {
      const [user, setUser] = useState<User | null>(null);
      const [isLoading, setIsLoading] = useState(true);
      const [error, setError] = useState<string | null>(null);
      const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
      
      // 初始化检测
      useEffect(() => {
        const initAuth = async () => {
          if (typeof window.ethereum !== 'undefined') {
            const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
            setProvider(web3Provider);
            
            // 检查已连接的账户
            try {
              const accounts = await window.ethereum.request({
                method: 'eth_accounts'
              });
              
              if (accounts.length > 0) {
                const address = accounts[0];
                const token = localStorage.getItem('auth_token');
                
                // 验证现有令牌
                if (token) {
                  const isValid = await validateToken(token);
                  if (isValid) {
                    setUser({
                      address,
                      isConnected: true,
                      isAuthenticated: true
                    });
                  } else {
                    localStorage.removeItem('auth_token');
                    setUser({
                      address,
                      isConnected: true,
                      isAuthenticated: false
                    });
                  }
                } else {
                  setUser({
                    address,
                    isConnected: true,
                    isAuthenticated: false
                  });
                }
              }
            } catch (err) {
              console.error('Init auth error:', err);
            }
          }
          
          setIsLoading(false);
        };
        
        initAuth();
        
        // 监听账户变化
        if (typeof window.ethereum !== 'undefined') {
          window.ethereum.on('accountsChanged', handleAccountsChanged);
          window.ethereum.on('chainChanged', handleChainChanged);
        }
        
        return () => {
          if (typeof window.ethereum !== 'undefined') {
            window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
            window.ethereum.removeListener('chainChanged', handleChainChanged);
          }
        };
      }, []);
      
      const handleAccountsChanged = useCallback((accounts: string[]) => {
        if (accounts.length === 0) {
          setUser(null);
        } else if (user) {
          setUser(prev => prev ? {
            ...prev,
            address: accounts[0],
            isAuthenticated: false
          } : null);
        }
      }, [user]);
      
      const handleChainChanged = useCallback(() => {
        // 链变化后刷新页面
        window.location.reload();
      }, []);
      
      const connect = async () => {
        if (!provider) {
          setError('No wallet detected');
          return;
        }
        
        setIsLoading(true);
        setError(null);
        
        try {
          await provider.send('eth_requestAccounts', []);
          const accounts = await provider.listAccounts();
          
          if (accounts.length > 0) {
            setUser({
              address: accounts[0].address,
              isConnected: true,
              isAuthenticated: false
            });
          }
        } catch (err) {
          setError(err instanceof Error ? err.message : 'Connection failed');
        } finally {
          setIsLoading(false);
        }
      };
      
      const disconnect = () => {
        localStorage.removeItem('auth_token');
        setUser(null);
      };
      
      const authenticate = async (): Promise<boolean> => {
        if (!provider || !user) return false;
        
        setIsLoading(true);
        setError(null);
        
        try {
          const signer = provider.getSigner();
          const address = await signer.getAddress();
          
          // 1. 获取挑战
          const challengeRes = await fetch('/api/auth/challenge', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ address })
          });
          
          if (!challengeRes.ok) throw new Error('Failed to get challenge');
          
          const { challenge } = await challengeRes.json();
          
          // 2. 签名
          const signature = await signer.signMessage(challenge);
          
          // 3. 验证
          const verifyRes = await fetch('/api/auth/verify', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ address, signature, challenge })
          });
          
          if (!verifyRes.ok) throw new Error('Verification failed');
          
          const { token } = await verifyRes.json();
          localStorage.setItem('auth_token', token);
          
          setUser(prev => prev ? {
            ...prev,
            isAuthenticated: true
          } : null);
          
          return true;
        } catch (err) {
          setError(err instanceof Error ? err.message : 'Authentication failed');
          return false;
        } finally {
          setIsLoading(false);
        }
      };
      
      const validateToken = async (token: string): Promise<boolean> => {
        try {
          const res = await fetch('/api/auth/validate', {
            headers: { 'Authorization': `Bearer ${token}` }
          });
          return res.ok;
        } catch {
          return false;
        }
      };
      
      return (
        <AuthContext.Provider value={{
          user,
          isLoading,
          error,
          connect,
          disconnect,
          authenticate
        }}>
          {children}
        </AuthContext.Provider>
      );
    }
    
    export function useAuth() {
      const context = useContext(AuthContext);
      if (!context) {
        throw new Error('useAuth must be used within AuthProvider');
      }
      return context;
    }
    

    4.2 认证组件

    tsx

    // 连接钱包按钮组件
    import { useAuth } from './AuthContext';
    import { Button } from './ui/Button';
    
    export function WalletConnectButton() {
      const { user, isLoading, error, connect, authenticate } = useAuth();
      
      if (isLoading) {
        return (
          <Button disabled>
            <span className="animate-spin mr-2">⏳</span>
            Loading...
          </Button>
        );
      }
      
      if (!user?.isConnected) {
        return (
          <Button onClick={connect}>
            Connect Wallet
          </Button>
        );
      }
      
      if (!user.isAuthenticated) {
        return (
          <Button onClick={authenticate}>
            Sign In
          </Button>
        );
      }
      
      return (
        <div className="flex items-center gap-3">
          <span className="text-sm text-gray-400">
            {user.address.slice(0, 6)}...{user.address.slice(-4)}
          </span>
          <Button variant="ghost">
            Disconnect
          </Button>
        </div>
      );
    }
    

    五、安全最佳实践

    5.1 防止常见攻击

    typescript

    // 安全检查工具
    export class SecurityUtils {
      // 验证地址格式
      static isValidAddress(address: string): boolean {
        return /^0x[a-fA-F0-9]{40}$/.test(address);
      }
      
      // 验证签名格式
      static isValidSignature(signature: string): boolean {
        return /^0x[a-fA-F0-9]{130}$/.test(signature);
      }
      
      // 防止重放攻击:检查nonce
      static async checkNonce(address: string): Promise<boolean> {
        const response = await fetch(`/api/auth/nonce/${address}`);
        const { used } = await response.json();
        return !used;
      }
      
      // 检测钓鱼风险
      static checkDomainMismatch(domain: string): boolean {
        const expectedDomain = process.env.REACT_APP_DOMAIN;
        return domain !== expectedDomain;
      }
      
      // 签名消息时显示清晰警告
      static getSignMessageWarning(domain: string): string {
        return `
    ⚠️ Security Notice:
    
    Before signing, please verify:
    1. URL is correct: https://${domain}
    2. You are not on a phishing site
    3. The message doesn't request token transfers
    
    This signature will NOT trigger any blockchain transaction.
        `.trim();
      }
    }
    

    5.2 认证中间件

    typescript

    // Express认证中间件
    import { Request, Response, NextFunction } from 'express';
    import { SessionManager } from './SessionManager';
    
    const sessionManager = new SessionManager();
    
    export interface AuthenticatedRequest extends Request {
      user?: {
        address: string;
        sessionId: string;
      };
    }
    
    export function authMiddleware(
      req: AuthenticatedRequest, 
      res: Response, 
      next: NextFunction
    ): void {
      const authHeader = req.headers.authorization;
      
      if (!authHeader?.startsWith('Bearer ')) {
        res.status(401).json({ error: 'No token provided' });
        return;
      }
      
      const token = authHeader.substring(7);
      const result = sessionManager.verifyToken(token);
      
      if (!result.valid || !result.payload) {
        res.status(401).json({ error: result.error });
        return;
      }
      
      req.user = {
        address: result.payload.address,
        sessionId: result.payload.sessionId
      };
      
      next();
    }
    
    // 可选认证中间件(不强制要求登录)
    export function optionalAuth(
      req: AuthenticatedRequest,
      res: Response,
      next: NextFunction
    ): void {
      const authHeader = req.headers.authorization;
      
      if (!authHeader?.startsWith('Bearer ')) {
        next();
        return;
      }
      
      const token = authHeader.substring(7);
      const result = sessionManager.verifyToken(token);
      
      if (result.valid && result.payload) {
        req.user = {
          address: result.payload.address,
          sessionId: result.payload.sessionId
        };
      }
      
      next();
    }
    

    六、总结

    本文系统讲解了DApp用户认证与会话管理的完整解决方案:

    1. 认证基础:理解了区块链身份与钱包的关系
    2. 签名认证:掌握了基于签名消息的认证流程
    3. 会话管理:学会了JWT令牌的生成、验证和刷新
    4. React集成:完成了认证上下文和组件的实现
    5. 安全实践:了解了常见攻击防护和最佳实践

    一个好的DApp认证系统应该在安全性和用户体验之间找到平衡。记住以下原则:

    • 始终使用挑战-响应的认证模式
    • JWT令牌要有合理的过期时间
    • 前端要处理好链变化和账户切换
    • 对敏感操作考虑二次验证

    相关推荐

  • Chainlink预言机集成开发:让智能合约访问链外真实世界数据

    Chainlink预言机集成开发:让智能合约访问链外真实世界数据

    为什么需要去中心化预言机

    你可能会问:中心化数据源不行吗?比如直接在合约里调用一个API获取价格。

    不行。原因是:如果数据源是中心化的,整个系统的安全性就取决于那个单点。想象你写了一个用某个交易所价格进行清算的合约,如果那个交易所宕机或者数据被篡改,你的合约就会出问题。更糟糕的是,链上无法验证这个数据是否被篡改。

    去中心化预言机通过多重机制解决这个风险:多个独立的数据源、多个独立的预言机节点、聚合算法处理数据。这让数据既难以被操控,又能保持高可用性。

    Chainlink四大核心功能解析,Data Feeds、VRF、Keepers、Functions应用场景

    Chainlink Data Feeds:获取价格数据

    这是Chainlink最常用的功能——为合约提供资产价格数据。Uniswap、Aave等主流DeFi协议都用它。

    工作原理

    Chainlink维护着一组价格对(如ETH/USD),每个价格由多个数据源聚合而来,经过去中心化预言机网络传输,最终存储在链上合约中。数据定期更新(通常每分钟或更快)。

    集成步骤

    第一步:找到对应的Feed地址

    每个链的Data Feed地址不同。以太坊主网的ETH/USD地址是:

    plaintext

    0x5f4eC3Df9cbd43714FE2740f5E3616185c4D31f7
    

    其他链和交易对的地址可以在Chainlink文档中找到。

    第二步:编写合约代码

    solidity

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.24;
    
    import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
    
    contract PriceConsumer {
        AggregatorV3Interface internal priceFeed;
        
        constructor() {
            // 初始化ETH/USD价格feed
            priceFeed = AggregatorV3Interface(
                0x5f4eC3Df9cbd43714FE2740f5E3616185c4D31f7
            );
        }
        
        /**
         * 获取最新价格
         * 返回 (roundId, price, startedAt, updatedAt, answeredInRound)
         */
        function getLatestPrice() public view returns (int256) {
            (
                /* uint80 roundId */,
                int256 price,
                /* uint256 startedAt */,
                /* uint256 updatedAt */,
                /* uint80 answeredInRound */
            ) = priceFeed.latestRoundData();
            return price;
        }
        
        /**
         * 获取指定轮次的价格
         */
        function getHistoricalPrice(uint80 roundId) public view returns (int256) {
            (
                uint80 id,
                int256 price,
                uint256 startedAt,
                uint256 updatedAt,
                uint80 answeredInRound
            ) = priceFeed.getRoundData(roundId);
            
            require(answeredInRound >= roundId, "Stale data");
            require(updatedAt > 0, "Round not complete");
            
            return price;
        }
    }
    

    第三步:理解返回数据

    latestRoundData()返回的结构需要特别注意:

    • price:价格,精度通常是8位小数。ETH/USD可能返回类似351200000000(3512.00美元)
    • updatedAt:数据更新时间戳
    • answeredInRound:确认轮次

    使用价格数据时,务必检查数据是否过期:

    solidity

    function getLatestPriceWithValidation() public view returns (int256) {
        (
            uint80 roundId,
            int256 price,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = priceFeed.latestRoundData();
        
        // 检查数据有效性
        require(price > 0, "Invalid price");
        require(answeredInRound >= roundId, "Stale data");
        
        // 检查数据是否太旧(超过3分钟)
        uint256 maxAge = 3 minutes;
        require(block.timestamp - updatedAt <= maxAge, "Price too old");
        
        return price;
    }
    

    Chainlink VRF:生成可验证随机数

    区块链上没有真正的随机数,因为所有节点必须能验证相同的结果。Chainlink VRF通过密码学证明解决了这个问题——随机数可以被链上验证是真随机,而不是被预言机节点操控的。

    请求随机数的模式

    VRF采用”请求-响应”模式:

    1. 合约发起随机数请求(需要支付LINK代币)
    2. 预言机生成随机数并附上证明
    3. 链上验证证明有效后,随机数才可用

    完整实现示例

    solidity

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.24;
    
    import "@chainlink/contracts/src/v0.8/VRFV2WrapperConsumerBase.sol";
    
    contract RandomNFT is VRFV2WrapperConsumerBase {
        uint256 public constant REQUEST_CONFIRMATIONS = 3;
        uint256 public constant NUM_WORDS = 1;
        uint256 public constant.callbackGasLimit = 100000;
        
        // 请求ID到请求信息的映射
        mapping(uint256 => address) public requestIdToSender;
        mapping(uint256 => uint256) public requestIdToSeed;
        
        // NFT属性配置
        uint256[] public rarityRanges = [70, 90, 98, 100];  // 普通70%, 稀有20%, 史诗7%, 传说3%
        
        constructor()
            VRFV2WrapperConsumerBase(
                0x271682DEB8C4E2001eb050A2AFb27Cf6a2A21D6d,  // VRF Coordinator
                0x326C977E6efc84E512bB9C30f76E30c160eD06FB   // LINK Token
            )
        {}
        
        /**
         * 请求随机数来铸造NFT
         */
        function requestMint() external returns (uint256 requestId) {
            requestId = requestRandomWords(
                REQUEST_CONFIRMATIONS,
                callbackGasLimit,
                NUM_WORDS
            );
            
            requestIdToSender[requestId] = msg.sender;
        }
        
        /**
         * VRF回调函数 - 接收随机数
         */
        function fulfillRandomWords(
            uint256 requestId,
            uint256[] memory randomWords
        ) internal override {
            uint256 randomValue = randomWords[0];
            
            // 根据随机数决定NFT稀有度
            uint256 rarity = determineRarity(randomValue);
            
            // 调用mint函数,传入稀有度
            mintNFT(requestIdToSender[requestId], rarity);
        }
        
        function determineRarity(uint256 random) public pure returns (uint256) {
            uint256 n = random % 100;
            
            if (n < rarityRanges[0]) return 0;  // 普通
            if (n < rarityRanges[1]) return 1;  // 稀有
            if (n < rarityRanges[2]) return 2;  // 史诗
            return 3;  // 传说
        }
        
        function mintNFT(address to, uint256 rarity) internal {
            // mint逻辑
        }
    }
    

    关键点解析

    REQUEST_CONFIRMATIONS:区块确认数,越多越安全但越慢。通常3-5个区块足够。

    callbackGasLimit:处理回调函数需要的Gas上限。如果你的回调逻辑复杂,需要设置足够高的值,否则请求会失败。

    请求费用估算

    solidity

    function getRequestFee() public view returns (uint256) {
        return VRF_V2_WRAPPER.requestSubscription() 
            ? VRF_V2_WRAPPER.calculateRequestPrice(callbackGasLimit)
            : 0;
    }
    

    实际使用中,建议先用测试网(Sepolia)测试费用,再部署到主网。

    Chainlink Keepers:自动化执行

    很多合约逻辑需要定期执行,比如清算、奖励分发等。Keepers替代了传统的定时任务方案,让合约可以在指定条件下自动触发。

    Keeper兼容合约的要求

    合约必须实现AutomationCompatibleInterface接口,提供两个关键函数:

    • checkUpkeep:检查是否需要执行
    • performUpkeep:执行具体逻辑

    实现自动债仓清算

    solidity

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.24;
    
    import "@chainlink/contracts/src/v0.8/automation/AutomationCompatibleInterface.sol";
    
    contract AutomatedLiquidation is AutomationCompatibleInterface {
        // 债仓信息
        struct Vault {
            address owner;
            uint256 collateral;
            uint256 debt;
        }
        
        mapping(uint256 => Vault) public vaults;
        mapping(address => uint256[]) public ownerVaults;
        
        // liquidation threshold: 健康系数低于此值会被清算
        uint256 public constant HEALTH_FACTOR_THRESHOLD = 1e18;
        
        // Chainlink注册时使用的 upkeepName
        function checkUpkeep(
            bytes calldata /* checkData */
        ) external override returns (bool upkeepNeeded, bytes memory performData) {
            // 检查是否有需要清算的债仓
            uint256[] memory toLiquidate = new uint256[](0);
            uint256 count = 0;
            
            for (uint256 i = 1; i <= vaultCount; i++) {
                if (needsLiquidation(vaults[i])) {
                    count++;
                }
            }
            
            if (count > 0) {
                // 收集需要清算的债仓ID
                toLiquidate = new uint256[](count);
                uint256 index = 0;
                for (uint256 i = 1; i <= vaultCount; i++) {
                    if (needsLiquidation(vaults[i])) {
                        toLiquidate[index++] = i;
                    }
                }
                upkeepNeeded = true;
                performData = abi.encode(toLiquidate);
            }
        }
        
        function performUpkeep(bytes calldata performData) external override {
            uint256[] memory toLiquidate = abi.decode(performData, (uint256[]));
            
            for (uint256 i = 0; i < toLiquidate.length; i++) {
                liquidateVault(toLiquidate[i]);
            }
        }
        
        function needsLiquidation(Vault memory vault) internal view returns (bool) {
            if (vault.collateral == 0) return false;
            
            // 获取当前ETH价格
            int256 price = getEthPrice();
            uint256 healthFactor = calculateHealthFactor(vault, price);
            
            return healthFactor < HEALTH_FACTOR_THRESHOLD;
        }
        
        function liquidateVault(uint256 vaultId) internal {
            Vault storage vault = vaults[vaultId];
            
            // 执行清算逻辑
            // 1. 计算清算收益
            // 2. 从Vault转出抵押品
            // 3. 销毁债务
            // 4. 触发事件
            
            emit VaultLiquidated(vaultId, vault.owner, vault.collateral);
        }
    }
    

    注册Keeper Upkeep

    部署合约后,需要在Chainlink自动化注册页面注册你的合约:

    1. 访问 app.chain.link/automation
    2. 连接钱包
    3. 选择”Custom logic”
    4. 填写合约地址
    5. 设置触发条件(时间或Gas)
    6. 质押LINK作为支付

    API Calls:自定义数据请求

    当Data Feeds和VRF都不满足需求时,Chainlink Functions(原External Adapters)允许你请求任意外部数据。

    solidity

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.24;
    
    import "@chainlink/contracts/src/v0.8/functions/FunctionsClient.sol";
    import "@chainlink/contracts/src/v0.8/functions/ConfirmedRequest.sol";
    
    contract SportsDataConsumer is FunctionsClient, ConfirmedRequest {
        using Functions for Functions.Request;
        
        // 比赛结果结构
        struct GameResult {
            string homeTeam;
            string awayTeam;
            uint8 homeScore;
            uint8 awayScore;
            string status;
        }
        
        mapping(bytes32 => GameResult) public requestResults;
        mapping(bytes32 => address) public requesters;
        
        // Chainlink Functions路由地址(不同链不同)
        constructor(address router) FunctionsClient(router) {}
        
        function requestGameResult(
            string memory apiUrl,
            string memory gameId
        ) external returns (bytes32 requestId) {
            // 构建请求
            Functions.Request memory req;
            req.initializeRequest(
                Functions.Location.Inline,
                Functions.CodeLanguage.JavaScript,
                _getSource(gameId)
            );
            
            // 添加Secrets(如果API需要认证)
            // req.addSecretsReference(_secretsLocation);
            
            // 添加回调参数
            req.addArgs(abi.encode(gameId));
            
            // 发送请求
            requestId = sendRequest(
                req,
                subscriptionId,
                gasLimit,
                bytes(functionsBillingRegistryProxy)
            );
            
            requesters[requestId] = msg.sender;
        }
        
        function fulfillRequest(
            bytes32 requestId,
            bytes memory response,
            bytes memory /* err */
        ) internal override {
            // 解析返回数据
            GameResult memory result = abi.decode(response, (GameResult));
            requestResults[requestId] = result;
            
            emit RequestFulfilled(requestId, result);
        }
        
        function _getSource(string memory gameId) internal pure returns (string memory) {
            return string(
                abi.encodePacked(
                    "const gameId = args[0];",
                    "const response = await fetch(`",
                    apiUrl,
                    "/games/${gameId}`);",
                    "const data = await response.json();",
                    "return Functions.encodeJSON(data);"
                )
            );
        }
    }
    

    使用Functions需要:

    1. 订阅Chainlink Functions服务
    2. 充值LINK用于支付费用
    3. 管理订阅或使用per-request付费

    生产环境最佳实践

    价格数据使用

    防御性编程:永远不要假设数据是新鲜的:

    solidity

    function safeGetPrice() internal view returns (int256) {
        (, int256 price, , uint256 updatedAt, ) = priceFeed.latestRoundData();
        
        // 时间容错
        if (block.timestamp - updatedAt > 5 minutes) {
            // 价格太旧,触发告警或使用fallback
            revert("Stale price data");
        }
        
        return price;
    }
    

    多头数据源:对关键操作,可以结合多个价格源取中位数:

    solidity

    int256[] memory prices = new int256[](3);
    prices[0] = getPriceFromFeed(feed1);
    prices[1] = getPriceFromFeed(feed2);
    prices[2] = getPriceFromFeed(feed3);
    
    return median(prices);
    

    VRF费用控制

    使用订阅模式统一管理费用:

    1. 创建订阅并充值LINK
    2. 将消费者合约添加到订阅
    3. 通过订阅支付费用,更灵活控制成本

    Keepergas优化

    checkUpkeep应该尽可能轻量,只做检查:

    solidity

    function checkUpkeep(bytes calldata) external returns (bool, bytes memory) {
        // 避免链上循环遍历
        // 只检查状态变量,不做复杂计算
        
        return (needsExecution, "");
    }
    

    复杂逻辑放到performUpkeep中,因为只有实际执行时才消耗Gas。

    总结

    Chainlink是区块链连接现实世界的重要基础设施。Data Feeds提供了可靠的价格数据,VRF解决了链上随机数问题,Keepers实现了合约自动化,Functions则支持自定义数据请求。

    集成这些服务时,记住几个原则:永远验证数据新鲜度,使用防御性编程,处理各种异常情况。先在测试网充分验证,再部署到主网。Chainlink的服务虽然强大,但正确使用才能发挥其价值。

  • DApp后端架构实战:构建可靠的区块链节点服务与中间件层

    DApp后端架构实战:构建可靠的区块链节点服务与中间件层

    栏目分类:DApp开发
    焦点关键词:DApp后端架构、区块链节点服务
    SEO标题:DApp后端架构实战:构建可靠的区块链节点服务与中间件层
    SEO摘要:本文深入讲解DApp后端系统的架构设计与实现,涵盖以太坊节点部署与维护、Web3中间件开发、区块链事件监听与索引、交易池管理等核心主题。通过Node.js和TypeScript的完整示例代码,帮助开发者构建高可用的DApp后端服务,适合有全栈开发经验想要进入Web3领域的工程师学习。

    为什么要自己维护后端?

    很多新手开发者习惯直接使用Infura、Alchemy等第三方节点服务,这确实能快速启动项目。但随着DApp用户量增长,你会遇到各种限制:免费套餐的请求频率限制、敏感业务逻辑不想暴露给前端、或者需要深度定制区块链数据的获取方式。

    本文将带你从零构建一套完整的DApp后端系统,包括节点部署、中间件开发、数据索引、交易管理等模块。这套架构适用于中大型DApp项目,日处理能力可达数十万请求。

    DApp后端四层技术栈架构,从以太坊节点集群到数据索引监控的完整链路

    以太坊节点部署与维护

    节点类型选择

    以太坊节点主要有两种类型,选择取决于你的使用场景:

    全节点(Full Node)

    • 存储完整的区块链数据(约1TB)
    • 验证所有区块和交易
    • 可以发起交易和读取历史数据
    • 适合需要完整功能的DApp

    存档节点(Archive Node)

    • 包含全节点的所有数据
    • 额外保存每个历史状态快照
    • 数据量巨大(约12TB)
    • 适合需要查询任意历史状态的服务(如区块浏览器)

    对于大多数DApp后端,全节点已经足够。

    Geth节点部署

    使用Docker部署Geth是最便捷的方式:

    yaml

    # docker-compose.yml
    version: '3.8'
    services:
      geth:
        image: ethereum/client-go:stable
        container_name: ethereum-node
        restart: unless-stopped
        ports:
          - "8545:8545"      # HTTP RPC
          - "8546:8546"      # WebSocket RPC
          - "30303:30303"    # P2P协议
        volumes:
          - ethereum-data:/root/.ethereum
        command: |
          --mainnet
          --http
          --http.addr 0.0.0.0
          --http.port 8545
          --http.corsdomain "*"
          --http.api eth,net,web3,txpool,debug
          --ws
          --ws.addr 0.0.0.0
          --ws.port 8546
          --ws.api eth,net,web3,txpool,debug
          --syncmode snap
          --gcmode archive
          --maxpeers 50
          --cache 8192
        environment:
          - GOMEMLIMIT=8GiB
    
    volumes:
      ethereum-data:
    

    启动节点:

    bash

    docker-compose up -d
    
    # 监控同步状态
    docker logs -f ethereum-node --tail 100
    
    # 检查同步进度
    curl -s -X POST http://localhost:8545 \
      -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}'
    

    高可用节点集群

    单节点有单点故障风险,生产环境应该部署多节点并使用负载均衡:

    yaml

    # docker-compose.cluster.yml
    version: '3.8'
    services:
      nginx:
        image: nginx:alpine
        container_name: eth-node-proxy
        restart: unless-stopped
        ports:
          - "8545:8545"
        volumes:
          - ./nginx.conf:/etc/nginx/nginx.conf:ro
        depends_on:
          - geth1
          - geth2
          - geth3
    
      geth1:
        image: ethereum/client-go:stable
        # ... 同上,移除端口映射(仅内部网络)
    
      geth2:
        image: ethereum/client-go:stable
        # ... 同上
    
      geth3:
        image: ethereum/client-go:stable
        # ... 同上
    

    nginx

    # nginx.conf
    events {
        worker_connections 1024;
    }
    
    stream {
        upstream ethereum_backend {
            least_conn;  # 最少连接策略
            server geth1:8545 max_fails=3 fail_timeout=10s;
            server geth2:8545 max_fails=3 fail_timeout=10s;
            server geth3:8545 max_fails=3 fail_timeout=10s;
        }
    
        server {
            listen 8545;
            proxy_pass ethereum_backend;
            proxy_timeout 10s;
            proxy_connect_timeout 5s;
        }
    }
    

    Web3中间件开发

    项目初始化

    使用TypeScript和Express构建企业级Web3服务:

    bash

    mkdir web3-backend && cd web3-backend
    npm init -y
    npm install express typescript @types/node @types/express ethers@6 nodemon ts-node
    npm install -D typescript @types/node
    npx tsc --init
    

    json

    // tsconfig.json
    {
      "compilerOptions": {
        "target": "ES2020",
        "module": "commonjs",
        "lib": ["ES2020"],
        "outDir": "./dist",
        "rootDir": "./src",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "resolveJsonModule": true
      },
      "include": ["src/**/*"],
      "exclude": ["node_modules"]
    }
    

    核心Provider封装

    封装统一的Provider层,统一处理连接、错误和重试:

    typescript

    // src/providers/JsonRpcProvider.ts
    import { ethers, JsonRpcProvider, BrowserProvider } from 'ethers';
    
    interface ProviderConfig {
      urls: string[];           // 多个RPC节点URL
      maxRetries: number;       // 最大重试次数
      retryDelay: number;       // 重试间隔(毫秒)
      timeout: number;          // 请求超时
      network: ethers.Networkish;
    }
    
    export class ResilientProvider {
      private providers: JsonRpcProvider[];
      private currentIndex: number = 0;
      private config: ProviderConfig;
    
      constructor(config: ProviderConfig) {
        this.config = config;
        this.providers = config.urls.map(
          url => new JsonRpcProvider(url, config.network)
        );
        
        // 设置默认超时
        this.providers.forEach(p => {
          p._getConnection().timeout = config.timeout;
        });
      }
    
      // 智能选择最优节点
      private selectProvider(): JsonRpcProvider {
        // 轮询策略,实际可实现加权随机或健康检查
        this.currentIndex = (this.currentIndex + 1) % this.providers.length;
        return this.providers[this.currentIndex];
      }
    
      // 带重试的请求
      async request<T>(
        method: string, 
        params: any[] = [],
        attempt: number = 0
      ): Promise<T> {
        const provider = this.selectProvider();
        
        try {
          const result = await provider.send(method, params);
          return result as T;
        } catch (error: any) {
          // 分类错误:如果是严重错误,不重试
          if (this.isFatalError(error)) {
            throw error;
          }
          
          // 可重试错误
          if (attempt < this.config.maxRetries) {
            await this.delay(this.config.retryDelay * Math.pow(2, attempt));
            return this.request<T>(method, params, attempt + 1);
          }
          
          throw error;
        }
      }
    
      // 获取最新区块号
      async getBlockNumber(): Promise<number> {
        return this.request('eth_blockNumber').then(n => parseInt(n, 16));
      }
    
      // 获取区块详情
      async getBlock(blockNumber: number): Promise<ethers.Block> {
        return this.request('eth_getBlockByNumber', [
          '0x' + blockNumber.toString(16),
          true  // 包含完整交易
        ]);
      }
    
      // 获取交易收据
      async getTransactionReceipt(hash: string): Promise<ethers.TransactionReceipt | null> {
        return this.request('eth_getTransactionReceipt', [hash]);
      }
    
      // 监听新区块
      subscribeNewHeads(callback: (block: ethers.Block) => void): ethers.Listener {
        const provider = this.providers[0]; // WebSocket provider
        return provider.on('block', callback);
      }
    
      private delay(ms: number): Promise<void> {
        return new Promise(resolve => setTimeout(resolve, ms));
      }
    
      private isFatalError(error: any): boolean {
        // 严重错误不重试:语法错误、方法不存在等
        return error?.code === -32603 || 
               error?.message?.includes('does not exist') ||
               error?.message?.includes('Invalid params');
      }
    }
    

    RESTful API服务

    构建完整的Web3 API服务:

    typescript

    // src/services/BlockService.ts
    import { ResilientProvider } from '../providers/JsonRpcProvider';
    import { ethers } from 'ethers';
    
    export interface BlockInfo {
      number: number;
      hash: string;
      parentHash: string;
      timestamp: number;
      transactions: string[];
      gasUsed: string;
      gasLimit: string;
    }
    
    export interface TransactionInfo {
      hash: string;
      blockNumber: number;
      from: string;
      to: string;
      value: string;
      gas: string;
      gasPrice: string;
      input: string;
      status: number;
    }
    
    export class BlockService {
      constructor(private provider: ResilientProvider) {}
    
      // 获取区块列表
      async getBlocks(fromBlock: number, toBlock: number): Promise<BlockInfo[]> {
        const blocks: BlockInfo[] = [];
        
        for (let i = fromBlock; i <= toBlock; i++) {
          try {
            const block = await this.provider.getBlock(i);
            if (block) {
              blocks.push(this.formatBlock(block));
            }
          } catch (error) {
            console.error(`Failed to fetch block ${i}:`, error);
          }
        }
        
        return blocks;
      }
    
      // 获取单区块详情
      async getBlock(blockNumber: number): Promise<BlockInfo | null> {
        try {
          const block = await this.provider.getBlock(blockNumber);
          return block ? this.formatBlock(block) : null;
        } catch {
          return null;
        }
      }
    
      // 获取区块内的交易列表
      async getBlockTransactions(blockNumber: number): Promise<TransactionInfo[]> {
        const block = await this.provider.getBlock(blockNumber);
        if (!block) return [];
    
        const transactions: TransactionInfo[] = [];
        
        for (const txHash of block.transactions) {
          const receipt = await this.provider.getTransactionReceipt(txHash);
          if (receipt) {
            transactions.push({
              hash: receipt.hash,
              blockNumber: receipt.blockNumber,
              from: receipt.from,
              to: receipt.to || '',
              value: receipt.value.toString(),
              gas: receipt.gas.toString(),
              gasPrice: receipt.gasPrice.toString(),
              input: receipt.data,
              status: receipt.status || 0
            });
          }
        }
        
        return transactions;
      }
    
      private formatBlock(block: ethers.Block): BlockInfo {
        return {
          number: block.number,
          hash: block.hash || '',
          parentHash: block.parentHash,
          timestamp: block.timestamp,
          transactions: block.transactions.map(tx => 
            typeof tx === 'string' ? tx : tx.hash
          ),
          gasUsed: block.gasUsed.toString(),
          gasLimit: block.gasLimit.toString()
        };
      }
    }
    

    交易管理服务

    处理交易签名和发送:

    typescript

    // src/services/TransactionService.ts
    import { ethers, Wallet, TransactionRequest, TransactionResponse } from 'ethers';
    import { ResilientProvider } from '../providers/JsonRpcProvider';
    
    export interface TransactionOptions {
      gasLimit?: bigint;
      gasPrice?: bigint;
      maxFeePerGas?: bigint;
      maxPriorityFeePerGas?: bigint;
      nonce?: number;
    }
    
    export class TransactionService {
      private wallet: Wallet;
    
      constructor(
        private provider: ResilientProvider,
        private privateKey: string
      ) {
        this.wallet = new Wallet(privateKey);
      }
    
      // 估算Gas费用(EIP-1559)
      async estimateGasFees(): Promise<{
        maxFeePerGas: bigint;
        maxPriorityFeePerGas: bigint;
      }> {
        const feeData = await this.provider.request<{
          baseFeePerGas: string;
          priorityFeePerGas: string;
        }>('eth_feeHistory', ['0x4', 'latest', [25, 75]]);
    
        const baseFee = BigInt(feeData.baseFeePerGas);
        const priorityFee = BigInt(feeData.priorityFeePerGas);
        
        const maxPriorityFeePerGas = priorityFee;
        const maxFeePerGas = baseFee * 2n + priorityFee;
        
        return { maxFeePerGas, maxPriorityFeePerGas };
      }
    
      // 构建交易
      async buildTransaction(
        to: string,
        value: bigint,
        data: string = '0x',
        options: TransactionOptions = {}
      ): Promise<TransactionRequest> {
        const nonce = options.nonce ?? await this.getNonce();
        const { maxFeePerGas, maxPriorityFeePerGas } = await this.estimateGasFees();
    
        return {
          to,
          value,
          data,
          nonce,
          chainId: 1,
          gasLimit: options.gasLimit || 21000n,
          maxFeePerGas: options.maxFeePerGas || maxFeePerGas,
          maxPriorityFeePerGas: options.maxPriorityFeePerGas || maxPriorityFeePerGas,
          type: 2,  // EIP-1559
          chainId: 1
        };
      }
    
      // 发送交易
      async sendTransaction(
        to: string,
        value: bigint,
        data: string = '0x',
        options: TransactionOptions = {}
      ): Promise<TransactionResponse> {
        const txRequest = await this.buildTransaction(to, value, data, options);
        const signedTx = await this.wallet.signTransaction(txRequest);
        const txHash = await this.provider.request<string>('eth_sendRawTransaction', [signedTx]);
        
        return this.provider.providers[0].getTransaction(txHash!) as Promise<TransactionResponse>;
      }
    
      // 等待交易确认
      async waitForTransaction(
        txHash: string,
        confirmations: number = 1
      ): Promise<ethers.TransactionReceipt> {
        const provider = this.provider.providers[0];
        return provider.waitForTransaction(txHash, confirmations);
      }
    
      // 获取nonce
      private async getNonce(): Promise<number> {
        return this.provider.request('eth_getTransactionCount', [
          this.wallet.address,
          'pending'
        ]).then(n => parseInt(n, 16));
      }
    
      // 批量发送交易
      async sendBatchTransactions(
        txs: Array<{
          to: string;
          value: bigint;
          data?: string;
        }>
      ): Promise<TransactionResponse[]> {
        const results: TransactionResponse[] = [];
        let nonce = await this.getNonce();
        
        for (const tx of txs) {
          try {
            const txRequest = await this.buildTransaction(
              tx.to,
              tx.value,
              tx.data || '0x',
              { nonce: nonce++ }
            );
            
            const signedTx = await this.wallet.signTransaction(txRequest);
            const txHash = await this.provider.request<string>('eth_sendRawTransaction', [signedTx]);
            
            results.push(await this.provider.providers[0].getTransaction(txHash!) as TransactionResponse);
          } catch (error) {
            console.error('Failed to send transaction:', error);
            throw error;
          }
        }
        
        return results;
      }
    }
    

    事件监听与索引

    WebSocket事件监听

    实时监听区块链事件:

    typescript

    // src/services/EventListener.ts
    import { ethers } from 'ethers';
    import { EventEmitter } from 'events';
    
    export interface TransferEvent {
      from: string;
      to: string;
      value: bigint;
      transactionHash: string;
      blockNumber: number;
      logIndex: number;
    }
    
    export class BlockchainEventListener extends EventEmitter {
      private provider: ethers.WebSocketProvider;
      private subscriptions: Map<string, ethers.Listener> = new Map();
    
      constructor(wsUrl: string) {
        super();
        this.provider = new ethers.WebSocketProvider(wsUrl);
        
        // 监听断线重连
        this.provider.websocket.on('close', () => {
          console.log('WebSocket disconnected, reconnecting...');
          setTimeout(() => this.reconnect(), 3000);
        });
      }
    
      // 监听新区块
      watchNewBlocks(callback: (blockNumber: number) => void): void {
        const handler = (blockNumber: bigint) => {
          callback(Number(blockNumber));
        };
        
        this.provider.on('block', handler);
        this.subscriptions.set('newBlocks', handler);
      }
    
      // 监听ERC20转账事件
      watchTokenTransfers(
        contractAddress: string,
        fromAddress?: string,
        toAddress?: string
      ): void {
        const topic0 = ethers.id('Transfer(address,address,uint256)');
        const topics = fromAddress
          ? [topic0, ethers.zeroPadValue(fromAddress, 32)]
          : toAddress
            ? [topic0, null, ethers.zeroPadValue(toAddress, 32)]
            : [topic0];
    
        const filter: ethers.Filter = {
          address: contractAddress,
          topics,
          fromBlock: 'latest'
        };
    
        const handler = (logs: ethers.Log[]) => {
          for (const log of logs) {
            const event = this.parseTransferEvent(log);
            if (event) {
              this.emit('Transfer', event);
            }
          }
        };
    
        this.provider.on(filter, handler);
        this.subscriptions.set(`transfer-${contractAddress}`, handler);
      }
    
      // 监听合约事件
      watchContractEvents(
        contractAddress: string,
        eventName: string,
        filter?: { [key: string]: string }
      ): void {
        const iface = new ethers.Interface([
          // 这里应该传入完整的ABI
          // 简化示例
        ]);
        
        const topic0 = ethers.id(eventName);
        const filterParams: ethers.Filter = {
          address: contractAddress,
          topics: filter ? [topic0] : [topic0],
          fromBlock: 'latest'
        };
    
        const handler = (logs: ethers.Log[]) => {
          for (const log of logs) {
            try {
              const parsed = iface.parseLog(log);
              if (parsed) {
                this.emit(eventName, {
                  args: parsed.args,
                  transactionHash: log.transactionHash,
                  blockNumber: log.blockNumber,
                  logIndex: log.index
                });
              }
            } catch (error) {
              console.error('Failed to parse event:', error);
            }
          }
        };
    
        this.provider.on(filterParams, handler);
        this.subscriptions.set(`${eventName}-${contractAddress}`, handler);
      }
    
      // 解析Transfer事件
      private parseTransferEvent(log: ethers.Log): TransferEvent | null {
        // ERC-20 Transfer signature: Transfer(address,address,uint256)
        // 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df35b9c8
        if (log.topics[0] !== '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df35b9c8') {
          return null;
        }
    
        return {
          from: ethers.getAddress('0x' + log.topics[1].slice(26)),
          to: ethers.getAddress('0x' + log.topics[2].slice(26)),
          value: BigInt(log.data),
          transactionHash: log.transactionHash!,
          blockNumber: log.blockNumber!,
          logIndex: log.index
        };
      }
    
      // 清理所有订阅
      async cleanup(): Promise<void> {
        for (const [key, handler] of this.subscriptions) {
          this.provider.off(key as any, handler);
        }
        this.subscriptions.clear();
        await this.provider.destroy();
      }
    
      private async reconnect(): Promise<void> {
        try {
          await this.provider.getBlockNumber();
          console.log('WebSocket reconnected');
          this.emit('reconnect');
        } catch {
          setTimeout(() => this.reconnect(), 3000);
        }
      }
    }
    

    数据持久化与索引

    将事件数据写入数据库:

    typescript

    // src/services/EventIndexer.ts
    import { BlockchainEventListener, TransferEvent } from './EventListener';
    import { Database } from './Database';
    
    export class EventIndexer {
      constructor(
        private listener: BlockchainEventListener,
        private db: Database
      ) {
        this.setupListeners();
      }
    
      private setupListeners(): void {
        // 监听转账事件并写入数据库
        this.listener.on('Transfer', async (event: TransferEvent) => {
          try {
            await this.db.query(
              `INSERT INTO transfers (
                tx_hash, block_number, log_index, 
                from_address, to_address, 
                value, created_at
              ) VALUES ($1, $2, $3, $4, $5, $6, NOW())
              ON CONFLICT (tx_hash, log_index) DO NOTHING`,
              [
                event.transactionHash,
                event.blockNumber,
                event.logIndex,
                event.from,
                event.to,
                event.value.toString()
              ]
            );
            
            console.log(`Indexed transfer: ${event.transactionHash}`);
          } catch (error) {
            console.error('Failed to index transfer:', error);
          }
        });
      }
    
      // 启动索引器
      async start(): Promise<void> {
        // 首次启动时,回溯历史数据
        await this.backfillHistoricalData();
        
        // 然后开始监听新区块
        this.listener.watchNewBlocks(async (blockNumber) => {
          console.log(`New block: ${blockNumber}`);
        });
      }
    
      // 回溯历史数据
      private async backfillHistoricalData(): Promise<void> {
        const lastIndexedBlock = await this.db.query(
          'SELECT MAX(block_number) as last_block FROM transfers'
        );
        
        const fromBlock = lastIndexedBlock.rows[0]?.last_block 
          ? Number(lastIndexedBlock.rows[0].last_block) + 1 
          : 0;
        
        console.log(`Backfilling from block ${fromBlock}...`);
        // 实现历史数据回填逻辑
        // 使用getBlockTransactions或事件过滤API
      }
    }
    

    Express API服务器

    整合所有服务:

    typescript

    // src/index.ts
    import express from 'express';
    import { ResilientProvider } from './providers/JsonRpcProvider';
    import { BlockService } from './services/BlockService';
    import { TransactionService } from './services/TransactionService';
    
    const app = express();
    app.use(express.json());
    
    // 初始化Provider和Services
    const provider = new ResilientProvider({
      urls: process.env.RPC_URLS!.split(','),
      maxRetries: 3,
      retryDelay: 1000,
      timeout: 30000,
      network: 'mainnet'
    });
    
    const blockService = new BlockService(provider);
    const txService = new TransactionService(
      provider, 
      process.env.PRIVATE_KEY!
    );
    
    // API路由
    app.get('/health', (req, res) => {
      res.json({ status: 'ok', timestamp: Date.now() });
    });
    
    app.get('/api/block/:blockNumber', async (req, res) => {
      try {
        const blockNumber = parseInt(req.params.blockNumber);
        const block = await blockService.getBlock(blockNumber);
        res.json(block);
      } catch (error: any) {
        res.status(500).json({ error: error.message });
      }
    });
    
    app.get('/api/block/:blockNumber/transactions', async (req, res) => {
      try {
        const blockNumber = parseInt(req.params.blockNumber);
        const txs = await blockService.getBlockTransactions(blockNumber);
        res.json(txs);
      } catch (error: any) {
        res.status(500).json({ error: error.message });
      }
    });
    
    app.get('/api/blocks', async (req, res) => {
      try {
        const from = parseInt(req.query.from as string);
        const to = parseInt(req.query.to as string);
        const blocks = await blockService.getBlocks(from, to);
        res.json(blocks);
      } catch (error: any) {
        res.status(500).json({ error: error.message });
      }
    });
    
    app.post('/api/transaction/send', async (req, res) => {
      try {
        const { to, value, data } = req.body;
        const tx = await txService.sendTransaction(
          to,
          BigInt(value),
          data || '0x'
        );
        res.json({ hash: tx.hash, status: 'pending' });
      } catch (error: any) {
        res.status(500).json({ error: error.message });
      }
    });
    
    app.get('/api/transaction/:hash', async (req, res) => {
      try {
        const receipt = await provider.request<any>('eth_getTransactionReceipt', [req.params.hash]);
        res.json(receipt);
      } catch (error: any) {
        res.status(500).json({ error: error.message });
      }
    });
    
    // 启动服务器
    const PORT = process.env.PORT || 3000;
    app.listen(PORT, () => {
      console.log(`Web3 API server running on port ${PORT}`);
    });
    

    监控与日志

    生产环境中,完善的监控必不可少:

    typescript

    // src/monitoring/Metrics.ts
    import { client, Counter, Histogram, Gauge } from 'prom-client';
    
    export const metrics = {
      httpRequests: new Counter({
        name: 'http_requests_total',
        help: 'Total HTTP requests',
        labelNames: ['method', 'path', 'status']
      }),
      
      rpcLatency: new Histogram({
        name: 'rpc_request_duration_seconds',
        help: 'RPC request latency',
        labelNames: ['method']
      }),
      
      activeConnections: new Gauge({
        name: 'active_websocket_connections',
        help: 'Number of active WebSocket connections'
      }),
      
      blocksBehind: new Gauge({
        name: 'blocks_behind_head',
        help: 'How many blocks behind the chain head'
      })
    };
    
    // 使用中间件收集指标
    export function metricsMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) {
      const start = Date.now();
      
      res.on('finish', () => {
        metrics.httpRequests.inc({
          method: req.method,
          path: req.path,
          status: res.statusCode
        });
      });
      
      next();
    }
    

    总结

    本文构建了一套完整的DApp后端架构,涵盖:

    节点管理:使用Docker和Nginx实现高可用的节点集群
    Provider封装:带重试和健康检查的弹性RPC调用
    业务服务:区块查询、交易管理、事件监听等核心功能
    数据索引:实时监听链上事件并持久化到数据库
    监控运维:Prometheus指标收集和日志管理

    这套架构可以支撑日处理数十万请求的DApp服务。实际项目中,可以根据业务需求添加缓存层(Redis)、消息队列(RabbitMQ)、微服务拆分等组件。

    下一篇文章我们将介绍智能合约监控与调试工具,帮助你在生产环境中更好地维护合约。

    相关阅读

  • The Graph数据索引实战:构建高效的链上数据查询层

    The Graph数据索引实战:构建高效的链上数据查询层

    引言

    链上数据的获取一直是DApp开发的痛点。想象一下:你要做一个DeFi Dashboard,需要展示某用户在Uniswap的所有交易历史。直接调用以太坊节点?每次查询可能需要几十秒,还可能因为数据量大而超时。把这逻辑搬到链下?又需要自己维护数据管道,处理区块同步和事件解析。

    The Graph给出了一个优雅的解决方案:去中心化的索引网络,让开发者可以定义”数据应该怎么组织”,然后由网络自动追踪链上变化、索引数据、供查询使用。这篇文章会带你从零开始掌握The Graph的核心概念,并通过实际案例学会构建Subgraph。

    The Graph数据索引架构流程图

    一、The Graph核心概念解析

    1.1 为什么需要The Graph

    先看一个具体场景:Uniswap在以太坊上部署了工厂合约,每当有人创建新的交易对,合约会发出PairCreated事件。

    如果用传统方式获取所有交易对:

    javascript

    // 直接调用以太坊节点(低效且复杂)
    const Web3 = require("web3");
    const web3 = new Web3("https://eth-mainnet.g.alchemy.com/...");
    
    // 获取创世块到当前块的所有PairCreated事件
    const events = await factory.getPastEvents("PairCreated", {
        fromBlock: 0,
        toBlock: "latest"
    });
    
    // 每一次DApp加载都要重复这个过程
    

    问题很明显:查询时间长、数据量大、浪费资源。

    使用The Graph后:

    graphql

    # 简单查询,毫秒级响应
    {
      pairs(first: 5, orderBy: createdAtTimestamp, orderDirection: desc) {
        id
        token0 { symbol }
        token1 { symbol }
        createdAtTimestamp
      }
    }
    

    网络中的索引节点会持续追踪合约事件,解析数据并存储,开发者只需要编写GraphQL查询。

    1.2 核心组件

    Subgraph(子图)

    Subgraph是The Graph的核心概念。它定义了三个部分:

    1. Manifest(清单):声明要索引哪些合约和事件
    2. Schema(模式):定义数据的结构和关系
    3. Mappings(映射):指定事件如何转换为数据

    plaintext

    my-subgraph/
    ├── subgraph.yaml          # Manifest定义
    ├── schema.graphql         # 数据模式
    ├── src/
    │   ├── mapping.ts         # 映射逻辑
    │   └── ...其他文件
    └── build/                 # 编译输出
    

    Graph Node(图形节点)

    Graph Node是索引服务,负责监控区块链、处理事件、存储索引数据。它会:

    • 监听区块链新区块
    • 识别属于Subgraph的事件
    • 执行映射函数
    • 更新GraphQL数据库

    Graph Network(图形网络)

    去中心化的索引网络。开发者可以付费使用索引服务(通过GRT代币),索引者可以赚取查询费用和索引奖励。

    1.3 工作流程

    plaintext

    ┌─────────────┐    事件    ┌──────────────┐
    │   智能合约  │ ────────→ │  Graph Node  │
    └─────────────┘           └──────────────┘
                                       │
                                       ▼
                                ┌──────────────┐
                                │  PostgreSQL  │
                                └──────────────┘
                                       │
                                       ▼
                                ┌──────────────┐
                                │  GraphQL API │
                                └──────────────┘
                                       │
                                       ▼
                                ┌──────────────┐
                                │    DApp      │
                                └──────────────┘
    

    二、Subgraph开发实战

    接下来通过一个实际案例来学习Subgraph开发。假设我们要为Uniswap V3的流动性事件构建索引。

    2.1 项目初始化

    使用Graph CLI创建Subgraph项目:

    bash

    # 全局安装Graph CLI
    npm install -g @graphprotocol/graph-cli
    
    # 初始化新项目
    graph init
    
    # 交互式配置
    # Network: Ethereum
    # Subgraph name: your-name/uniswap-v3-liquidities
    # Directory: uniswap-v3-liquidities
    # Contract name: UniswapV3
    # Contract address: 0x1F98431c8aD98523631AE4a59f267346ea31F984 (Uniswap V3 Core)
    # Contract ABI: Auto-fetch
    # Index contract events: Yes
    # Add other contract: Yes (NonfungiblePositionManager)
    

    2.2 Schema设计

    Schema定义了你要索引的数据结构。使用GraphQL类型系统。

    graphql

    # schema.graphql
    
    # 代币实体
    type Token @entity {
      id: ID!                    # 地址作为ID
      symbol: String!
      name: String!
      decimals: BigInt!
      totalSupply: BigInt!
      
      # 关系:代币参与的所有池子
      pools: [Pool!]! @derivedFrom(field: "token0")
      pools1: [Pool!]! @derivedFrom(field: "token1")
    }
    
    # 流动性池实体
    type Pool @entity {
      id: ID!                    # 池子地址
      token0: Token!
      token1: Token!
      fee: BigInt!               # 费率 (3000 = 0.3%)
      
      # 流动性相关字段
      liquidity: BigInt!
      sqrtPrice: BigInt!
      tick: BigInt!
      
      # 时间戳
      createdAtTimestamp: BigInt!
      createdAtBlockNumber: BigInt!
      
      # 关系:池子的流动性位置
      positions: [Position!]! @derivedFrom(field: "pool")
    }
    
    # 流动性头寸实体
    type Position @entity {
      id: ID!                    # tokenId或组合键
      
      # 所有者
      owner: Bytes!
      
      # 所属池子
      pool: Pool!
      
      # 流动性参数
      tickLower: BigInt!
      tickUpper: BigInt!
      liquidity: BigInt!
      
      # 交易数据
      depositedToken0: BigDecimal!
      depositedToken1: BigDecimal!
      withdrawnToken0: BigDecimal!
      withdrawnToken1: BigDecimal!
      
      # 时间戳
      transaction: Transaction!
      createdAtTimestamp: BigInt!
      createdAtBlockNumber: BigInt!
    }
    
    # 交易记录(用于关联多个事件)
    type Transaction @entity {
      id: ID!                    # 交易哈希
      blockNumber: BigInt!
      timestamp: BigInt!
      
      # 关联事件
      mints: [Mint!]!
      burns: [Burn!]!
      collects: [Collect!]!
    }
    

    字段类型说明:

    • ID!:主键,必须唯一
    • String!:非空字符串
    • BigInt!:大整数,用于代币金额(Solidity的uint256映射)
    • BigDecimal!:高精度小数,用于费率计算
    • Bytes!:字节数组,用于地址
    • @entity:表示这是持久化实体
    • @derivedFrom:反向关系,自动生成查询字段

    2.3 Manifest配置

    subgraph.yaml声明要索引的合约:

    yaml

    # subgraph.yaml
    specVersion: 1.0.0
    indexerHints:
      prune: auto
    
    repository: https://github.com/your-name/uniswap-v3-liquidities
    schema:
      file: ./schema.graphql
    
    dataSources:
      # Uniswap V3 Core (Factory)
      - kind: ethereum
        name: UniswapV3Factory
        network: mainnet
        source:
          address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
          abi: Factory
          startBlock: 12369621  # Uniswap V3部署区块
        mapping:
          kind: ethereum/events
          apiVersion: 0.0.7
          language: wasm/assemblyscript
          entities:
            - Pool
            - Token
          abis:
            - name: Factory
              file: ./abis/Factory.json
            - name: Pool
              file: ./abis/Pool.json
          eventHandlers:
            - event: PoolCreated(indexed address, indexable address, indexable address, uint256, indexable bytes32)
              handler: handlePoolCreated
          file: ./src/factory.ts
      
      # NonfungiblePositionManager
      - kind: ethereum
        name: NonfungiblePositionManager
        network: mainnet
        source:
          address: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88"
          abi: NonfungiblePositionManager
          startBlock: 12369621
        mapping:
          kind: ethereum/events
          apiHandle: 0.0.7
          language: wasm/assemblyscript
          entities:
            - Position
            - Transaction
            - Mint
            - Burn
            - Collect
          abis:
            - name: NonfungiblePositionManager
              file: ./abis/NonfungiblePositionManager.json
            - name: Pool
              file: ./abis/Pool.json
          eventHandlers:
            - event: IncreaseLiquidity(indexed uint256, uint128, uint256, uint256)
              handler: handleIncreaseLiquidity
            - event: DecreaseLiquidity(indexed uint256, uint128, uint256, uint256)
              handler: handleDecreaseLiquidity
            - event: Collect(indexed uint256, address, uint256, uint256)
              handler: handleCollect
            - event: Transfer(indexed address, indexed address, indexed uint256)
              handler: handleTransfer
          file: ./src/positions.ts
    

    2.4 映射逻辑实现

    映射文件处理事件,将链上数据转换为实体。

    工厂合约映射(处理池子创建):

    typescript

    // src/factory.ts
    import { PoolCreated } from "../generated/UniswapV3Factory/Factory";
    import { Pool, Token } from "../generated/schema";
    import { Pool as PoolContract } from "../generated/templates";
    
    export function handlePoolCreated(event: PoolCreated): void {
      // 1. 创建Token实体(如果不存在)
      let token0 = Token.load(event.params.token0.toHexString());
      if (!token0) {
        token0 = new Token(event.params.token0.toHexString());
        token0.symbol = "";
        token0.name = "";
        token0.decimals = BigInt.fromI32(18);
        token0.totalSupply = BigInt.zero();
        token0.save();
      }
    
      let token1 = Token.load(event.params.token1.toHexString());
      if (!token1) {
        token1 = new Token(event.params.token1.toHexString());
        token1.symbol = "";
        token1.name = "";
        token1.decimals = BigInt.fromI32(18);
        token1.totalSupply = BigInt.zero();
        token1.save();
      }
    
      // 2. 创建Pool实体
      let pool = new Pool(event.params.pool.toHexString());
      pool.token0 = token0.id;
      pool.token1 = token1.id;
      pool.fee = event.params.fee;
      pool.liquidity = BigInt.zero();
      pool.sqrtPrice = BigInt.zero();
      pool.tick = BigInt.zero();
      pool.createdAtTimestamp = event.block.timestamp;
      pool.createdAtBlockNumber = event.block.number;
      pool.save();
    
      // 3. 动态模板:为新池子创建索引任务
      PoolContract.create(event.params.pool);
    }
    

    位置管理器映射(处理流动性操作):

    typescript

    // src/positions.ts
    import {
      IncreaseLiquidity,
      DecreaseLiquidity,
      Collect,
      Transfer,
    } from "../generated/NonfungiblePositionManager/NonfungiblePositionManager";
    import {
      Position,
      Token,
      Transaction,
      Mint,
      Burn,
      Collect as CollectEntity,
    } from "../generated/schema";
    import { constants } from "@graphprotocol/graph-ts";
    
    // 全局代币精度映射
    let BIGINT_ZERO = constants.BIGINT_ZERO;
    let MantissaFormula = BigInt.fromI32(10).pow(18);
    
    export function handleIncreaseLiquidity(event: IncreaseLiquidity): void {
      // 加载或创建Position
      let positionId = event.params.tokenId.toString();
      let position = Position.load(positionId);
      
      if (!position) {
        // 首次增加流动性,需要从链上获取元数据
        position = new Position(positionId);
        position.owner = constants.ADDRESS_ZERO; // 临时值
        position.pool = "";
        position.tickLower = BIGINT_ZERO;
        position.tickUpper = BIGINT_ZERO;
        position.liquidity = BIGINT_ZERO;
        position.depositedToken0 = constants.BIGDECIMAL_ZERO;
        position.depositedToken1 = constants.BIGDECIMAL_ZERO;
        position.withdrawnToken0 = constants.BIGDECIMAL_ZERO;
        position.withdrawnToken1 = constants.BIGDECIMAL_ZERO;
        position.transaction = event.transaction.hash;
      }
    
      // 更新存款金额
      position.depositedToken0 = position.depositedToken0.plus(
        event.params.amount0.toBigDecimal().div(MantissaFormula)
      );
      position.depositedToken1 = position.depositedToken1.plus(
        event.params.amount1.toBigDecimal().div(MantissaFormula)
      );
    
      // 更新流动性
      position.liquidity = position.liquidity.plus(event.params.liquidity);
      position.save();
    
      // 创建Mint事件记录
      let mint = new Mint(
        event.transaction.hash.toHex() + "-" + event.logIndex.toString()
      );
      mint.position = position.id;
      mint.transaction = position.transaction;
      mint.timestamp = event.block.timestamp;
      mint.owner = position.owner;
      mint.origin = event.transaction.from;
      mint.amount = event.params.liquidity;
      mint.amount0 = event.params.amount0;
      mint.amount1 = event.params.amount1;
      mint.save();
    }
    
    export function handleDecreaseLiquidity(event: DecreaseLiquidity): void {
      let positionId = event.params.tokenId.toString();
      let position = Position.load(positionId);
      
      if (!position) {
        return;
      }
    
      // 更新提取金额
      position.withdrawnToken0 = position.withdrawnToken0.plus(
        event.params.amount0.toBigDecimal().div(MantissaFormula)
      );
      position.withdrawnToken1 = position.withdrawnToken1.plus(
        event.params.amount1.toBigDecimal().div(MantissaFormula)
      );
    
      // 更新流动性
      position.liquidity = position.liquidity.minus(event.params.liquidity);
      position.save();
    
      // 创建Burn事件记录
      let burn = new Burn(
        event.transaction.hash.toHex() + "-" + event.logIndex.toString()
      );
      burn.position = position.id;
      burn.transaction = position.transaction;
      burn.timestamp = event.block.timestamp;
      burn.owner = position.owner;
      burn.origin = event.transaction.from;
      burn.amount = event.params.liquidity;
      burn.amount0 = event.params.amount0;
      burn.amount1 = event.params.amount1;
      burn.save();
    }
    
    export function handleTransfer(event: Transfer): void {
      // 更新头寸所有权
      let positionId = event.params.tokenId.toString();
      let position = Position.load(positionId);
      
      if (position) {
        // 新的所有者
        if (event.params.to.notEqual(constants.ADDRESS_ZERO)) {
          position.owner = event.params.to;
          position.save();
        }
      }
    }
    

    2.5 辅助函数和工具

    为了处理复杂的计算,建议抽取通用逻辑:

    typescript

    // src/utils/positions.ts
    import { BigDecimal, BigInt } from "@graphprotocol/graph-ts";
    
    export let BI_18 = BigInt.fromI32(18);
    export let DECIMAL_18 = BigInt.fromString("10").pow(18).toBigDecimal();
    
    export function powDecimal(base: BigInt, exponent: number): BigDecimal {
      return base.toBigDecimal().div(DECIMAL_18);
    }
    
    export function convertTokenToDecimal(
      tokenAmount: BigInt,
      decimals: number
    ): BigDecimal {
      return tokenAmount
        .toBigDecimal()
        .div(BigInt.fromI32(10).pow(decimals as u8).toBigDecimal());
    }
    
    export function tokenAmountToDecimal(
      amount: BigInt,
      decimals: BigInt
    ): BigDecimal {
      return amount.toBigDecimal().div(
        BigInt.fromI32(10).pow(decimals.toI32() as u8).toBigDecimal()
      );
    }
    

    三、编译和部署

    3.1 本地开发验证

    在部署到主网之前,先在本地测试:

    bash

    # 1. 安装依赖
    yarn install
    
    # 2. 生成代码(根据schema和abi生成TypeScript绑定)
    graph codegen
    
    # 3. 编译Subgraph
    graph build
    
    # 4. 启动本地Graph Node(需要Docker)
    docker-compose up -d
    
    # 5. 创建本地Subgraph
    graph create uniswap-v3-liquidities \
      --node http://127.0.0.1:8020
    
    # 6. 部署到本地
    graph deploy uniswap-v3-liquidities \
      --ipfs http://127.0.0.1:5001 \
      --node http://127.0.0.1:8020 \
      --deploy-key <your-deploy-key>
    

    3.2 Graph Studio部署

    Graph提供托管服务(Graph Studio):

    1. 访问 https://thegraph.com/studio/
    2. 连接钱包
    3. 创建Subgraph
    4. 部署代码

    bash

    # 使用Graph Studio
    graph auth https://api.thegraph.com/deploy/ <your-access-token>
    
    graph deploy your-name/uniswap-v3-liquidities
    

    3.3 监控索引状态

    部署后需要监控索引进度:

    graphql

    # 查询索引状态
    {
      indexingStatusForCurrentVersion(
        subgraphName: "your-name/uniswap-v3-liquidities"
      ) {
        chains {
          latestBlock {
            number
            hash
          }
          chainHeadBlock {
            number
          }
        }
        entityCount
        synced
      }
    }
    

    四、DApp集成

    4.1 客户端查询

    通过GraphQL API查询索引数据:

    typescript

    // src/queries/uniswap.ts
    import { gql } from "@apollo/client";
    
    export const GET_POSITIONS = gql`
      query GetUserPositions($owner: Bytes!) {
        positions(where: { owner: $owner }) {
          id
          pool {
            id
            token0 { symbol decimals }
            token1 { symbol decimals }
            fee
          }
          tickLower
          tickUpper
          liquidity
          depositedToken0
          depositedToken1
          withdrawnToken0
          withdrawnToken1
        }
      }
    `;
    
    export const GET_POOL_SWAP = gql`
      query GetPoolSwaps($poolId: String!, $first: Int!) {
        swaps(
          where: { pool: $poolId }
          first: $first
          orderBy: timestamp
          orderDirection: desc
        ) {
          id
          timestamp
          amount0
          amount1
          sqrtPrice
          tick
        }
      }
    `;
    

    4.2 实际使用示例

    typescript

    // src/hooks/usePositions.ts
    import { useQuery } from "@apollo/client";
    import { GET_POSITIONS } from "../queries/uniswap";
    
    export function useUserPositions(owner: string) {
      const { loading, error, data } = useQuery(GET_POSITIONS, {
        variables: { owner: owner.toLowerCase() },
        pollInterval: 10000, // 每10秒刷新
      });
    
      return {
        positions: data?.positions || [],
        loading,
        error,
      };
    }
    

    typescript

    // src/components/PositionList.tsx
    import { useUserPositions } from "../hooks/usePositions";
    
    export function PositionList({ walletAddress }: { walletAddress: string }) {
      const { positions, loading, error } = useUserPositions(walletAddress);
    
      if (loading) return <div>Loading positions...</div>;
      if (error) return <div>Error loading positions</div>;
    
      return (
        <div>
          <h2>Your Liquidity Positions</h2>
          {positions.length === 0 ? (
            <p>No active positions</p>
          ) : (
            <ul>
              {positions.map((pos) => (
                <li key={pos.id}>
                  {pos.pool.token0.symbol}/{pos.pool.token1.symbol}
                  {" Pool Fee: "}{Number(pos.pool.fee) / 10000}%
                  {" Liquidity: "}{pos.liquidity}
                </li>
              ))}
            </ul>
          )}
        </div>
      );
    }
    

    五、性能优化技巧

    5.1 批量写入

    AssemblyScript的entity store操作较慢,可以用loadInBatches批量加载:

    typescript

    // ❌ 低效:逐个加载
    for (let i = 0; i < tokenIds.length; i++) {
      let token = Token.load(tokenIds[i]);
      // ...
    }
    
    // ✅ 高效:批量加载
    let tokens = Token.load(tokenIds);
    for (let i = 0; i < tokens.length; i++) {
      let token = tokens[i];
      if (token) {
        // 处理token
      }
    }
    

    5.2 条件索引

    只在需要时创建实体:

    typescript

    export function handleTransfer(event: Transfer): void {
      // 只在目标地址非零时创建/更新Position
      let positionId = event.params.tokenId.toString();
      
      // 加载检查
      if (event.params.to.notEqual(constants.ADDRESS_ZERO)) {
        let position = Position.load(positionId);
        if (position) {
          position.owner = event.params.to;
          position.save();
        }
      }
    }
    

    5.3 数据分页

    大量数据使用分页查询:

    typescript

    // 分页获取数据
    const PAGE_SIZE = 1000;
    let lastId = "";
    
    while (true) {
      let entities = ContractEvent.loadBatch(
        PAGE_SIZE,
        (entity) => entity.id > lastId
      );
      
      if (entities.length === 0) break;
      
      for (let entity of entities) {
        // 处理...
      }
      
      lastId = entities[entities.length - 1].id;
    }
    

    结语

    The Graph解决了链上数据获取的核心痛点,让开发者可以专注于业务逻辑而非数据管道。通过本文的实战案例,你应该已经掌握了Subgraph开发的核心流程:从Schema设计到Manifest配置,从映射逻辑编写到部署查询。

    需要注意的是,Subgraph开发有其适用场景:对于需要跨多个合约聚合数据的场景、频繁查询历史数据的场景,The Graph是理想选择。但对于简单的实时交互,直接调用合约可能更高效。根据具体需求选择合适的方案,才是明智的做法。

    相关阅读

  • Web3.js与React构建DApp前端实战:从连接到交互

    Web3.js与React构建DApp前端实战:从连接到交互

    DApp前端开发概述

    去中心化应用的前端开发与传统Web应用有着本质的不同。传统应用中,前端通过HTTP请求与后端API交互,后端再与数据库通信。而在DApp中,前端直接与区块链上的智能合约交互,所有的数据操作都发生在链上。

    这种架构带来了几个独特的挑战:首先,用户需要使用加密钱包而不是传统的用户名密码登录;其次,每次写操作都需要用户签名并支付Gas费用;最后,链上数据的获取需要理解事件机制和索引服务。掌握这些差异,是成为合格DApp开发者的关键。

    本教程将带你从零构建一个完整的DApp前端,涵盖钱包连接、余额查询、Token转账等核心功能。你将学会如何将Web3.js与React无缝结合,构建用户体验良好的去中心化应用。

    React DApp 前端钱包连接、ERC20 代币转账、链上事件监听区块链开发界面演示

    项目初始化

    创建React项目

    首先创建一个新的React项目,使用Vite作为构建工具可以获得更好的开发体验:

    bash

    npm create vite@latest my-dapp -- --template react
    cd my-dapp
    npm install
    

    安装Web3.js和其他必要依赖:

    bash

    npm install web3 ethers @rainbow-me/rainbowkit wagmi viem
    npm install @tanstack/react-query
    npm install lucide-react
    npm install -D tailwindcss postcss autoprefixer
    npx tailwindcss init -p
    

    配置Tailwind CSS

    更新tailwind.config.js配置:

    javascript

    /** @type {import('tailwindcss').Config} */
    export default {
      content: [
        "./index.html",
        "./src/**/*.{js,ts,jsx,tsx}",
      ],
      theme: {
        extend: {
          colors: {
            primary: '#3b82f6',
            secondary: '#10b981',
          }
        },
      },
      plugins: [],
    }
    

    添加Tailwind指令到index.css:

    css

    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    
    body {
      margin: 0;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
        'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
        sans-serif;
      -webkit-font-smoothing: antiali-serif;
      -moz-osx-font-smoothing: grayscale;
    }
    

    钱包连接组件

    钱包连接是DApp的门面,一个好的连接体验对用户至关重要。我们将构建一个支持多种钱包的连接组件。

    基础Web3Provider设置

    使用ethers.js创建Web3Provider是最简单的方式:

    javascript

    // src/web3/web3Provider.js
    import { ethers } from 'ethers';
    
    class Web3Service {
      constructor() {
        this.provider = null;
        this.signer = null;
        this.network = null;
      }
    
      async connect() {
        if (typeof window.ethereum !== 'undefined') {
          try {
            // 请求钱包连接
            const accounts = await window.ethereum.request({
              method: 'eth_requestAccounts'
            });
            
            // 创建provider
            this.provider = new ethers.BrowserProvider(window.ethereum);
            this.signer = await this.provider.getSigner();
            
            // 获取网络信息
            this.network = await this.provider.getNetwork();
            
            console.log('Connected to:', accounts[0]);
            return {
              account: accounts[0],
              provider: this.provider,
              signer: this.signer,
              network: this.network
            };
          } catch (error) {
            console.error('Connection failed:', error);
            throw error;
          }
        } else {
          throw new Error('MetaMask not installed');
        }
      }
    
      async disconnect() {
        this.provider = null;
        this.signer = null;
        this.network = null;
      }
    
      isConnected() {
        return this.provider !== null;
      }
    }
    
    export const web3Service = new Web3Service();
    

    这个服务类封装了钱包连接的核心逻辑。使用BrowserProvider(ethers v6的新API)可以自动处理现代钱包的连接请求。

    钱包连接组件实现

    jsx

    // src/components/WalletConnect.jsx
    import { useState, useEffect } from 'react';
    import { web3Service } from '../web3/web3Provider';
    import { formatAddress } from '../utils/format';
    
    export function WalletConnect() {
      const [account, setAccount] = useState(null);
      const [balance, setBalance] = useState(null);
      const [isConnecting, setIsConnecting] = useState(false);
      const [error, setError] = useState(null);
    
      // 检查是否已连接
      useEffect(() => {
        const checkConnection = async () => {
          if (window.ethereum) {
            const accounts = await window.ethereum.request({
              method: 'eth_accounts'
            });
            
            if (accounts.length > 0) {
              try {
                const { account, signer, provider } = await web3Service.connect();
                setAccount(account);
                
                // 获取余额
                const balance = await provider.getBalance(account);
                setBalance(ethers.utils.formatEther(balance));
              } catch (err) {
                console.error('Auto-connect failed:', err);
              }
            }
          }
        };
        
        checkConnection();
        
        // 监听账户变化
        window.ethereum.on('accountsChanged', handleAccountsChanged);
        window.ethereum.on('chainChanged', handleChainChanged);
        
        return () => {
          if (window.ethereum.removeListener) {
            window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
            window.ethereum.removeListener('chainChanged', handleChainChanged);
          }
        };
      }, []);
    
      const handleAccountsChanged = async (accounts) => {
        if (accounts.length === 0) {
          setAccount(null);
          setBalance(null);
          await web3Service.disconnect();
        } else if (accounts[0] !== account) {
          setAccount(accounts[0]);
          // 刷新页面以获取新的余额和状态
          window.location.reload();
        }
      };
    
      const handleChainChanged = () => {
        // 链变化时刷新页面
        window.location.reload();
      };
    
      const connect = async () => {
        setIsConnecting(true);
        setError(null);
        
        try {
          const { account, provider } = await web3Service.connect();
          setAccount(account);
          
          const balance = await provider.getBalance(account);
          setBalance(ethers.utils.formatEther(balance));
        } catch (err) {
          setError(err.message);
        } finally {
          setIsConnecting(false);
        }
      };
    
      const disconnect = async () => {
        await web3Service.disconnect();
        setAccount(null);
        setBalance(null);
      };
    
      if (!account) {
        return (
          <button
            onClick={connect}
            disabled={isConnecting}
            className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition-all disabled:opacity-50"
          >
            {isConnecting ? '连接中...' : '连接钱包'}
          </button>
        );
      }
    
      return (
        <div className="flex items-center gap-4">
          <div className="bg-gray-100 dark:bg-gray-800 rounded-lg px-4 py-2">
            <p className="text-sm text-gray-500">余额</p>
            <p className="font-bold">{parseFloat(balance).toFixed(4)} ETH</p>
          </div>
          
          <div className="bg-blue-50 dark:bg-blue-900/30 rounded-lg px-4 py-2">
            <p className="text-sm text-gray-500">地址</p>
            <p className="font-bold">{formatAddress(account)}</p>
          </div>
          
          <button
            onClick={disconnect}
            className="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded-lg transition-all"
          >
            断开
          </button>
        </div>
      );
    }
    

    这个组件处理了连接、断开连接、账户变化监听等完整的钱包交互逻辑。格式化地址的辅助函数可以这样实现:

    javascript

    // src/utils/format.js
    export function formatAddress(address) {
      if (!address) return '';
      return `${address.slice(0, 6)}...${address.slice(-4)}`;
    }
    
    export function formatEther(wei, decimals = 4) {
      const ether = parseFloat(ethers.utils.formatEther(wei));
      return ether.toFixed(decimals);
    }
    
    export function parseEther(ether) {
      return ethers.utils.parseEther(ether.toString());
    }
    

    智能合约交互

    连接钱包后,下一步是与智能合约交互。我们将创建一个通用的合约服务类,封装常见的合约操作。

    合约服务封装

    javascript

    // src/web3/contractService.js
    import { ethers } from 'ethers';
    
    export class ContractService {
      constructor(contractAddress, abi, signer) {
        this.contract = new ethers.Contract(contractAddress, abi, signer);
        this.address = contractAddress;
      }
    
      // 只读方法调用
      async call(methodName, ...args) {
        try {
          const result = await this.contract[methodName](...args);
          return result;
        } catch (error) {
          console.error(`Contract call ${methodName} failed:`, error);
          throw error;
        }
      }
    
      // 写操作(需要签名和Gas)
      async send(methodName, ...args) {
        try {
          const tx = await this.contract[methodName](...args);
          console.log('Transaction sent:', tx.hash);
          
          // 等待交易确认
          const receipt = await tx.wait();
          console.log('Transaction confirmed:', receipt.hash);
          
          return {
            hash: tx.hash,
            receipt,
            success: receipt.status === 1
          };
        } catch (error) {
          console.error(`Contract send ${methodName} failed:`, error);
          throw error;
        }
      }
    
      // 监听事件
      on(eventName, callback) {
        this.contract.on(eventName, (args) => {
          callback(args);
        });
      }
    
      // 移除事件监听
      off(eventName, callback) {
        if (callback) {
          this.contract.off(eventName, callback);
        } else {
          this.contract.removeAllListeners(eventName);
        }
      }
    
      // 获取历史事件
      async getPastEvents(eventName, fromBlock = 0, toBlock = 'latest') {
        return await this.contract.queryFilter(
          eventName,
          fromBlock,
          toBlock
        );
      }
    }
    

    ERC20 Token合约交互示例

    javascript

    // src/contracts/TokenContract.js
    import { ContractService } from './contractService';
    
    // ERC20代币ABI(简化版)
    const ERC20_ABI = [
      "function name() view returns (string)",
      "function symbol() view returns (string)",
      "function decimals() view returns (uint8)",
      "function totalSupply() view returns (uint256)",
      "function balanceOf(address) view returns (uint256)",
      "function transfer(address, uint256) returns (bool)",
      "function allowance(address, address) view returns (uint256)",
      "function approve(address, uint256) returns (bool)",
      "function transferFrom(address, address, uint256) returns (bool)",
      "event Transfer(address indexed from, address indexed to, uint256 value)",
      "event Approval(address indexed owner, address indexed spender, uint256 value)"
    ];
    
    export class TokenContract extends ContractService {
      constructor(address, signer) {
        super(address, ERC20_ABI, signer);
      }
    
      async getTokenInfo() {
        const [name, symbol, decimals, totalSupply] = await Promise.all([
          this.call('name'),
          this.call('symbol'),
          this.call('decimals'),
          this.call('totalSupply')
        ]);
        
        return {
          name,
          symbol,
          decimals,
          totalSupply: ethers.utils.formatUnits(totalSupply, decimals)
        };
      }
    
      async getBalance(address) {
        const balance = await this.call('balanceOf', address);
        return balance;
      }
    
      async transfer(to, amount) {
        const decimals = await this.call('decimals');
        const parsedAmount = ethers.utils.parseUnits(amount.toString(), decimals);
        return await this.send('transfer', to, parsedAmount);
      }
    
      async approve(spender, amount) {
        const decimals = await this.call('decimals');
        const parsedAmount = ethers.utils.parseUnits(amount.toString(), decimals);
        return await this.send('approve', spender, parsedAmount);
      }
    }
    

    Token转账组件

    jsx

    // src/components/TokenTransfer.jsx
    import { useState, useEffect } from 'react';
    import { web3Service } from '../web3/web3Provider';
    import { TokenContract } from '../contracts/TokenContract';
    import { formatAddress, formatEther } from '../utils/format';
    
    const TOKEN_ADDRESS = '0x1234567890123456789012345678901234567890';
    
    export function TokenTransfer() {
      const [tokenContract, setTokenContract] = useState(null);
      const [tokenInfo, setTokenInfo] = useState(null);
      const [balance, setBalance] = useState(null);
      const [recipient, setRecipient] = useState('');
      const [amount, setAmount] = useState('');
      const [isLoading, setIsLoading] = useState(false);
      const [txHash, setTxHash] = useState(null);
      const [error, setError] = useState(null);
    
      useEffect(() => {
        const initContract = async () => {
          if (web3Service.isConnected() && web3Service.signer) {
            const contract = new TokenContract(TOKEN_ADDRESS, web3Service.signer);
            setTokenContract(contract);
            
            // 获取代币信息
            const info = await contract.getTokenInfo();
            setTokenInfo(info);
            
            // 获取余额
            const signer = await web3Service.signer;
            const signerAddress = await signer.getAddress();
            const bal = await contract.getBalance(signerAddress);
            setBalance(formatEther(bal, info.decimals));
          }
        };
        
        initContract();
      }, []);
    
      const handleTransfer = async (e) => {
        e.preventDefault();
        setError(null);
        setTxHash(null);
        setIsLoading(true);
        
        try {
          const result = await tokenContract.transfer(recipient, amount);
          setTxHash(result.hash);
          
          // 刷新余额
          const signer = await web3Service.signer;
          const signerAddress = await signer.getAddress();
          const bal = await tokenContract.getBalance(signerAddress);
          setBalance(formatEther(bal, tokenInfo.decimals));
          
          // 清空表单
          setRecipient('');
          setAmount('');
        } catch (err) {
          setError(err.reason || err.message);
        } finally {
          setIsLoading(false);
        }
      };
    
      if (!tokenContract) {
        return <div>请先连接钱包</div>;
      }
    
      return (
        <div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
          <h2 className="text-2xl font-bold mb-4">
            {tokenInfo?.name || 'Token'} ({tokenInfo?.symbol}) 转账
          </h2>
          
          <div className="mb-6 p-4 bg-gray-100 dark:bg-gray-700 rounded-lg">
            <p className="text-gray-600 dark:text-gray-300">你的余额</p>
            <p className="text-3xl font-bold">
              {balance || '0'} {tokenInfo?.symbol}
            </p>
          </div>
          
          <form onSubmit={handleTransfer} className="space-y-4">
            <div>
              <label className="block text-sm font-medium mb-2">收款地址</label>
              <input
                type="text"
                value={recipient}
                onChange={(e) => setRecipient(e.target.value)}
                placeholder="0x..."
                className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
                required
              />
            </div>
            
            <div>
              <label className="block text-sm font-medium mb-2">数量</label>
              <input
                type="number"
                value={amount}
                onChange={(e) => setAmount(e.target.value)}
                placeholder="0.0"
                step="0.0001"
                className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
                required
              />
            </div>
            
            {error && (
              <div className="p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg">
                {error}
              </div>
            )}
            
            {txHash && (
              <div className="p-3 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg">
                转账成功!交易哈希: {formatAddress(txHash)}
              </div>
            )}
            
            <button
              type="submit"
              disabled={isLoading}
              className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition-all disabled:opacity-50"
            >
              {isLoading ? '处理中...' : '转账'}
            </button>
          </form>
        </div>
      );
    }
    

    事件监听与实时更新

    区块链的状态变化主要通过事件(Events)来追踪。学会监听和使用事件是DApp开发的核心技能。

    事件监听组件

    jsx

    // src/components/TransactionHistory.jsx
    import { useState, useEffect } from 'react';
    import { ethers } from 'ethers';
    import { TokenContract } from '../contracts/TokenContract';
    
    export function TransactionHistory({ tokenContract, account }) {
      const [events, setEvents] = useState([]);
      const [loading, setLoading] = useState(true);
    
      useEffect(() => {
        if (!tokenContract || !account) return;
    
        const fetchHistoricalEvents = async () => {
          try {
            // 获取最近的转账事件
            const transferFilter = tokenContract.contract.filters.Transfer(null, account);
            const toMe = await tokenContract.getPastEvents('Transfer', -10000, 'latest');
            
            const transferFromFilter = tokenContract.contract.filters.Transfer(account, null);
            const fromMe = await tokenContract.getPastEvents('Transfer', -10000, 'latest');
            
            // 合并并排序
            const allEvents = [...toMe, ...fromMe]
              .filter(e => 
                e.args.from.toLowerCase() === account.toLowerCase() ||
                e.args.to.toLowerCase() === account.toLowerCase()
              )
              .sort((a, b) => b.blockNumber - a.blockNumber)
              .slice(0, 50);
            
            setEvents(allEvents);
          } catch (error) {
            console.error('Failed to fetch events:', error);
          } finally {
            setLoading(false);
          }
        };
    
        fetchHistoricalEvents();
    
        // 监听新事件
        const handleNewTransfer = (from, to, value, event) => {
          if (
            from.toLowerCase() === account.toLowerCase() ||
            to.toLowerCase() === account.toLowerCase()
          ) {
            setEvents(prev => [event, ...prev].slice(0, 50));
          }
        };
    
        tokenContract.on('Transfer', handleNewTransfer);
    
        return () => {
          tokenContract.off('Transfer', handleNewTransfer);
        };
      }, [tokenContract, account]);
    
      if (loading) {
        return <div>加载中...</div>;
      }
    
      return (
        <div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg">
          <h2 className="text-2xl font-bold mb-4">交易历史</h2>
          
          {events.length === 0 ? (
            <p className="text-gray-500">暂无交易记录</p>
          ) : (
            <div className="space-y-3">
              {events.map((event, index) => {
                const isIncoming = event.args.to.toLowerCase() === account.toLowerCase();
                return (
                  <div
                    key={`${event.transactionHash}-${event.logIndex}`}
                    className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg"
                  >
                    <div>
                      <p className={`font-bold ${isIncoming ? 'text-green-600' : 'text-red-600'}`}>
                        {isIncoming ? '收到' : '转出'}
                      </p>
                      <p className="text-sm text-gray-500">
                        {formatAddress(event.args.from)} → {formatAddress(event.args.to)}
                      </p>
                    </div>
                    <div className="text-right">
                      <p className={`font-bold ${isIncoming ? 'text-green-600' : 'text-red-600'}`}>
                        {isIncoming ? '+' : '-'}{formatEther(event.args.value, 4)}
                      </p>
                      <p className="text-xs text-gray-400">
                        Block #{event.blockNumber}
                      </p>
                    </div>
                  </div>
                );
              })}
            </div>
          )}
        </div>
      );
    }
    

    网络切换与链兼容性

    现代DApp通常需要支持多个区块链网络。处理网络切换是必备技能。

    网络配置

    javascript

    // src/config/networks.js
    export const NETWORKS = {
      mainnet: {
        chainId: 1,
        name: 'Ethereum Mainnet',
        rpcUrl: 'https://mainnet.infura.io/v3/YOUR_PROJECT_ID',
        blockExplorer: 'https://etherscan.io'
      },
      sepolia: {
        chainId: 11155111,
        name: 'Sepolia Testnet',
        rpcUrl: 'https://sepolia.infura.io/v3/YOUR_PROJECT_ID',
        blockExplorer: 'https://sepolia.etherscan.io'
      },
      polygon: {
        chainId: 137,
        name: 'Polygon Mainnet',
        rpcUrl: 'https://polygon-rpc.com',
        blockExplorer: 'https://polygonscan.com'
      }
    };
    
    // 切换网络函数
    export async function switchNetwork(targetChainId) {
      const chainIdHex = `0x${targetChainId.toString(16)}`;
      
      try {
        await window.ethereum.request({
          method: 'wallet_switchEthereumChain',
          params: [{ chainId: chainIdHex }]
        });
      } catch (switchError) {
        // 如果网络不存在,添加网络
        if (switchError.code === 4902) {
          const network = Object.values(NETWORKS).find(n => n.chainId === targetChainId);
          if (network) {
            await window.ethereum.request({
              method: 'wallet_addEthereumChain',
              params: [{
                chainId: chainIdHex,
                chainName: network.name,
                nativeCurrency: {
                  name: 'ETH',
                  symbol: 'ETH',
                  decimals: 18
                },
                rpcUrls: [network.rpcUrl],
                blockExplorerUrls: [network.blockExplorer]
              }]
            });
          }
        } else {
          throw switchError;
        }
      }
    }
    

    网络切换组件

    jsx

    // src/components/NetworkSelector.jsx
    import { NETWORKS, switchNetwork } from '../config/networks';
    
    export function NetworkSelector({ currentChainId, onSwitch }) {
      const [isOpen, setIsOpen] = useState(false);
      const [isSwitching, setIsSwitching] = useState(false);
    
      const handleSwitch = async (chainId) => {
        setIsSwitching(true);
        try {
          await switchNetwork(chainId);
          onSwitch?.(chainId);
        } catch (error) {
          console.error('Network switch failed:', error);
        } finally {
          setIsSwitching(false);
          setIsOpen(false);
        }
      };
    
      const currentNetwork = Object.values(NETWORKS).find(
        n => n.chainId === currentChainId
      );
    
      return (
        <div className="relative">
          <button
            onClick={() => setIsOpen(!isOpen)}
            className="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 px-4 py-2 rounded-lg"
          >
            <div className="w-3 h-3 rounded-full bg-green-500"></div>
            <span>{currentNetwork?.name || 'Unknown Network'}</span>
            <ChevronDownIcon className="w-4 h-4" />
          </button>
          
          {isOpen && (
            <div className="absolute top-full mt-2 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-xl z-50">
              {Object.values(NETWORKS).map(network => (
                <button
                  key={network.chainId}
                  onClick={() => handleSwitch(network.chainId)}
                  disabled={isSwitching}
                  className={`w-full text-left px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 first:rounded-t-lg last:rounded-b-lg ${
                    network.chainId === currentChainId ? 'bg-blue-50 dark:bg-blue-900/30' : ''
                  }`}
                >
                  {network.name}
                </button>
              ))}
            </div>
          )}
        </div>
      );
    }
    

    完整的DApp主界面

    将所有组件整合到主应用中:

    jsx

    // src/App.jsx
    import { useState, useEffect } from 'react';
    import { WalletConnect } from './components/WalletConnect';
    import { TokenTransfer } from './components/TokenTransfer';
    import { TransactionHistory } from './components/TransactionHistory';
    import { NetworkSelector } from './components/NetworkSelector';
    import { web3Service } from './web3/web3Provider';
    import { TokenContract } from './contracts/TokenContract';
    
    const TOKEN_ADDRESS = '0x1234567890123456789012345678901234567890';
    
    function App() {
      const [account, setAccount] = useState(null);
      const [chainId, setChainId] = useState(null);
      const [tokenContract, setTokenContract] = useState(null);
    
      useEffect(() => {
        const checkConnection = async () => {
          if (window.ethereum) {
            const accounts = await window.ethereum.request({
              method: 'eth_accounts'
            });
            
            if (accounts.length > 0) {
              setAccount(accounts[0]);
              
              const provider = new ethers.BrowserProvider(window.ethereum);
              const signer = await provider.getSigner();
              setTokenContract(new TokenContract(TOKEN_ADDRESS, signer));
              
              const network = await provider.getNetwork();
              setChainId(Number(network.chainId));
            }
          }
        };
        
        checkConnection();
        
        window.ethereum?.on('accountsChanged', (accounts) => {
          if (accounts.length > 0) {
            setAccount(accounts[0]);
          } else {
            setAccount(null);
            setTokenContract(null);
          }
        });
        
        window.ethereum?.on('chainChanged', () => {
          window.location.reload();
        });
      }, []);
    
      return (
        <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
          {/* Header */}
          <header className="bg-white dark:bg-gray-800 shadow-sm">
            <div className="container mx-auto px-4 py-4 flex justify-between items-center">
              <h1 className="text-2xl font-bold">My DApp</h1>
              <div className="flex items-center gap-4">
                {chainId && <NetworkSelector currentChainId={chainId} />}
                <WalletConnect />
              </div>
            </div>
          </header>
          
          {/* Main Content */}
          <main className="container mx-auto px-4 py-8">
            <div className="grid md:grid-cols-2 gap-8">
              {account && <TokenTransfer />}
              {account && tokenContract && (
                <TransactionHistory tokenContract={tokenContract} account={account} />
              )}
            </div>
          </main>
          
          {/* Footer */}
          <footer className="mt-auto py-6 text-center text-gray-500">
            <p>Built with Web3.js and React</p>
          </footer>
        </div>
      );
    }
    
    export default App;
    

    总结与最佳实践

    DApp前端开发与传统Web开发有显著差异。首先,永远不要假设用户已经安装了钱包,优雅地处理钱包缺失是基本要求。其次,处理加载状态和错误状态,网络请求可能因为区块链拥堵而延迟。

    Gas费用的估算和显示对用户体验至关重要。在允许用户提交交易前,最好先估算Gas费用并告知用户。 ethers.js的estimateGas和getFeeData方法可以帮助实现这一点。

    事件监听是保持UI与链上状态同步的关键。但要注意,过多的事件监听可能影响性能,应该在组件卸载时及时移除监听器。

    网络切换需要处理钱包未安装、网络不支持等各种边界情况。提供一个清晰的网络列表,并确保切换失败时给出友好的错误提示。

    最后,安全性是DApp开发的重中之重。永远不要在客户端存储私钥或敏感信息,所有签名操作都应该通过用户钱包确认。对于复杂的合约交互,提供清晰的交易预览,让用户知道他们将要执行什么操作。

    掌握了这些核心技能后,你已经具备了开发大多数DApp的能力。随着经验积累,你会逐渐形成自己的最佳实践,构建出更安全、更高效的DApp应用。

    相关推荐