阅读视图

发现新文章,点击刷新页面。

Web3表单签名验证:我如何用 wagmi 和 siwe 让用户“无密码”登录

背景:用户提交地址,后端凭什么相信?

几个月前,我在做一个 DeFi 策略管理平台的前端。用户可以在上面创建“自动复投”策略,然后通过我们的合约执行。流程很简单:前端收集用户输入的策略参数(比如目标池地址、复投频率),然后调用合约。

但问题出在“用户身份”上。后端需要记录每个用户创建了哪些策略,但用户并没有注册流程,也没有密码。他们只是连接了钱包(MetaMask 或 WalletConnect),然后直接操作。后端收到的请求里,用户传一个 userAddress 字段,比如 0x1234...

我当时就想:这太不安全了。如果某个恶意用户伪造一个请求,把 userAddress 改成别人的地址,后端怎么知道这个地址真的是当前操作者?更糟的是,我们的后端还依赖这个地址来查询用户的历史数据,如果地址被篡改,数据就全乱了。

我需要一种方法:让后端能够验证“当前请求确实来自某个地址的持有者”,而且这个过程不能依赖密码,必须完全基于区块链钱包的签名机制。

问题分析:为什么简单的签名不行?

我最初的想法很简单:让前端用 ethers.js 对一段固定字符串签名,然后把签名和地址一起发给后端,后端用 ethers.utils.verifyMessage 验证。

// 最初的错误思路
const message = "I am the owner of this address";
const signature = await signer.signMessage(message);
// 然后发 { address, signature } 给后端

这看起来没问题,但实际跑起来就发现一堆坑:

  1. 重放攻击:如果签名被截获,攻击者可以重复使用这个签名来冒充用户。因为消息是固定的,签名永远有效。
  2. 过期问题:没有时间戳,后端不知道这个签名是什么时候签的。如果用户忘记断开连接,别人拿到这个签名可以一直用。
  3. 跨域问题:如果用户在不同 dApp 上签名了同样的消息,攻击者可以拿到签名后在我们的后端使用。

我当时就踩了这个坑:上线第一天,团队安全审计就说“这个方案不能上线,太脆弱了”。后来我才知道,社区早就有一个标准解决方案——EIP-4361,也就是“Sign-In with Ethereum”(SIWE)。

核心实现:用 siwe 构造防重放签名

SIWE 的核心思想是:把签名消息变成一个结构化的对象,包含 domain(域名)、uri(当前页面)、nonce(随机数)、issuedAt(签发时间)等字段。这样每个签名都是唯一的、有时效的、绑定到特定网站的。

我选择了 siwe 这个 npm 包,配合 wagmi v2 的 useSignMessage hook 来实现。

第一步:前端生成 nonce 并让用户签名

这里有个关键点:nonce 必须由后端生成,否则前端自己生成的 nonce 没有意义。所以我先向后端请求一个 nonce。

// 1. 从后端获取 nonce
const getNonce = async (): Promise<string> => {
  const res = await fetch('/api/auth/nonce');
  const data = await res.json();
  return data.nonce;
};

// 2. 构造 SIWE 消息
import { SiweMessage } from 'siwe';
import { useSignMessage, useAccount } from 'wagmi';

function LoginButton() {
  const { address, chainId } = useAccount();
  const { signMessageAsync } = useSignMessage();

  const handleLogin = async () => {
    if (!address || !chainId) return;

    // 注意:domain 必须和你的前端域名一致,否则验证会失败
    const domain = window.location.host;
    const origin = window.location.origin;

    const nonce = await getNonce();

    const siweMessage = new SiweMessage({
      domain,
      address,
      statement: 'Sign in to DeFi Dashboard to manage your strategies.',
      uri: origin,
      version: '1',
      chainId,
      nonce,
      issuedAt: new Date().toISOString(),
    });

    const message = siweMessage.prepareMessage();
    const signature = await signMessageAsync({ message });

    // 发送给后端验证
    const verifyRes = await fetch('/api/auth/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message, signature }),
    });

    if (verifyRes.ok) {
      // 登录成功,后端返回一个 session token
      const { token } = await verifyRes.json();
      localStorage.setItem('auth_token', token);
    }
  };

  return <button onClick={handleLogin}>Sign in with Ethereum</button>;
}

这里有个坑domain 字段必须精确匹配。我当时在本地开发时用的是 localhost:3000,但部署后域名变成了 app.example.com,结果生产环境一直报 Domain mismatch。后来我把 domain 从 window.location.host 获取,问题解决。

第二步:后端验证签名并创建 session

后端我用 Node.js + Express 实现,使用 siwe 包进行验证。验证通过后,我生成一个 JWT token 返回给前端,后续的 API 请求都带上这个 token。

// 后端:验证签名
import { SiweMessage } from 'siwe';
import express from 'express';

const app = express();
app.use(express.json());

// 存储 nonce(生产环境应该用 Redis)
const nonceStore: Set<string> = new Set();

// 生成 nonce 接口
app.get('/api/auth/nonce', (req, res) => {
  const nonce = generateRandomNonce(); // 使用 crypto.randomBytes 生成
  nonceStore.add(nonce);
  // 设置过期时间,比如 5 分钟
  setTimeout(() => nonceStore.delete(nonce), 5 * 60 * 1000);
  res.json({ nonce });
});

// 验证签名接口
app.post('/api/auth/verify', async (req, res) => {
  const { message, signature } = req.body;

  try {
    const siweMessage = new SiweMessage(message);
    const fields = await siweMessage.verify({
      signature,
      // 这里传入 nonce 是为了验证 nonce 是否有效
      nonce: siweMessage.nonce,
      // 这里传入 domain 是为了验证域名
      domain: siweMessage.domain,
    });

    // 验证成功后,从存储中删除 nonce,防止重放
    nonceStore.delete(siweMessage.nonce);

    // 生成 JWT token
    const token = jwt.sign(
      { address: fields.data.address, chainId: fields.data.chainId },
      process.env.JWT_SECRET,
      { expiresIn: '24h' }
    );

    res.json({ token });
  } catch (error) {
    console.error('Verification failed:', error);
    res.status(401).json({ error: 'Invalid signature' });
  }
});

注意这个细节siweMessage.verify 方法内部会自动检查 nonce、domain、过期时间等。如果 nonce 已经被使用过(比如重放攻击),就会抛出异常。我一开始没理解这个机制,以为需要手动检查,后来发现包已经帮我做了。

第三步:session 持久化与自动登录

用户每次刷新页面都要重新签名,体验很差。所以我用 JWT token 做 session 持久化。前端在初始化时检查 localStorage 中是否有 token,如果有就自动恢复登录状态。

// 封装一个 hook 来管理认证状态
import { useAccount, useDisconnect } from 'wagmi';
import { useState, useEffect } from 'react';

export function useAuth() {
  const { address, isConnected } = useAccount();
  const { disconnect } = useDisconnect();
  const [isAuthenticated, setIsAuthenticated] = useState(false);

  // 检查是否有有效的 token
  useEffect(() => {
    const token = localStorage.getItem('auth_token');
    if (token && address) {
      // 可以验证 token 是否过期(简单做法:解码 payload 检查 exp)
      setIsAuthenticated(true);
    }
  }, [address]);

  // 登出
  const logout = () => {
    localStorage.removeItem('auth_token');
    setIsAuthenticated(false);
    disconnect();
  };

  return { isAuthenticated, logout };
}

这里有个坑:JWT token 过期后,用户需要重新签名。我最初没有处理 token 过期的情况,结果用户操作到一半突然报 401 错误,体验非常糟糕。后来我加了一个“静默刷新”机制:在 API 请求拦截器中检查 token 是否即将过期,如果是,就弹出一个轻提示让用户重新签名。

完整代码:一个可运行的 React 组件

下面是一个完整的登录组件,包含签名验证和 session 管理。假设你已经配置好了 wagmi 的 provider。

// LoginWithSiwe.tsx
import { useState } from 'react';
import { useAccount, useSignMessage, useDisconnect } from 'wagmi';
import { SiweMessage } from 'siwe';
import { useAuth } from './useAuth';

export default function LoginWithSiwe() {
  const { address, isConnected, chainId } = useAccount();
  const { signMessageAsync } = useSignMessage();
  const { disconnect } = useDisconnect();
  const { isAuthenticated, logout } = useAuth();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const handleLogin = async () => {
    if (!address || !chainId) {
      setError('Please connect your wallet first');
      return;
    }

    setLoading(true);
    setError('');

    try {
      // 1. 获取 nonce
      const nonceRes = await fetch('/api/auth/nonce');
      const { nonce } = await nonceRes.json();

      // 2. 构造 SIWE 消息
      const message = new SiweMessage({
        domain: window.location.host,
        address,
        statement: 'Sign in to access your dashboard.',
        uri: window.location.origin,
        version: '1',
        chainId,
        nonce,
        issuedAt: new Date().toISOString(),
      });

      // 3. 签名
      const signature = await signMessageAsync({
        message: message.prepareMessage(),
      });

      // 4. 发送给后端验证
      const verifyRes = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          message: message.prepareMessage(),
          signature,
        }),
      });

      if (!verifyRes.ok) {
        throw new Error('Verification failed');
      }

      const { token } = await verifyRes.json();
      localStorage.setItem('auth_token', token);
      // 触发状态更新
      window.location.reload();
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Login failed');
    } finally {
      setLoading(false);
    }
  };

  if (isAuthenticated) {
    return (
      <div>
        <p>Logged in as: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
        <button onClick={logout}>Logout</button>
      </div>
    );
  }

  return (
    <div>
      {!isConnected ? (
        <p>Please connect your wallet first</p>
      ) : (
        <button onClick={handleLogin} disabled={loading}>
          {loading ? 'Signing...' : 'Sign in with Ethereum'}
        </button>
      )}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

踩坑记录

  1. Domain mismatch 错误:在本地开发时,domain 是 localhost:3000,但部署到生产环境后,domain 变成了 app.example.com。SIWE 验证要求 domain 必须精确匹配前端域名。解决方案:使用 window.location.host 动态获取 domain。

  2. Nonce already used 错误:第一次测试时,我在短时间内连续点击登录按钮,结果第二次签名时后端报错。原因是 nonce 只能使用一次,我忘记在验证成功后删除 nonce。解决方案:在验证成功后立即从存储中删除 nonce。

  3. 签名弹窗不显示:使用 wagmi 的 useSignMessage 时,如果用户已经连接了钱包,但 MetaMask 不弹出签名窗口。后来发现是因为我传入了 { message } 而不是 { message: siweMessage.prepareMessage() }prepareMessage() 方法会把结构化消息转成符合 EIP-4361 格式的字符串,MetaMask 才能正确识别。

  4. JWT token 过期后用户无感知:用户登录后,如果 token 过期了,API 请求会返回 401,但前端没有提示。解决方案:在 API 请求拦截器中检查 token 的 exp 字段,如果即将过期,提前弹出提示让用户重新签名。

小结

通过 EIP-4361 + SIWE,我成功实现了一套无需密码、基于钱包签名的身份认证方案。核心收获是:不要自己造轮子,社区标准方案(SIWE)已经解决了重放攻击、过期、跨域等问题。如果想继续深挖,可以研究 EIP-4361 的扩展(如 EIP-5573),或者结合 SIWE 实现更细粒度的权限控制。

从ethers.js迁移到Viem:我在重构DeFi前端时踩过的那些坑

背景

上个月,我接手了一个DeFi借贷平台的前端重构任务。这个项目三年前用ethers.js v5开发,代码里到处都是BigNumber的手动转换、冗长的Provider初始化,还有一堆自定义的类型补丁。最头疼的是,每次添加新链支持,都得手动配置RPC节点和链ID映射,维护成本越来越高。

团队里新来的同事抱怨说:“现在社区新项目都用Viem了,咱们这代码看着像古董。”我查了一下,Viem确实有不少吸引人的地方:更小的包体积、更好的TypeScript支持、内置的多链工具。更重要的是,我们计划集成AA(账户抽象)钱包,Viem对EIP-4337的原生支持是个巨大优势。

于是,我决定用一周时间,把核心的链上交互模块从ethers.js迁移到Viem。本以为就是换个库,改改API调用,结果第一天的进展就让我意识到,这趟水比想象中深。

问题分析

我的迁移策略很直接:先不动UI层,只替换底层的链上交互逻辑。我创建了一个useViemClient的Hook,打算逐步替换项目中几十个用到ethers.providers.Web3Provider的地方。

第一个问题很快就出现了。原来的代码里到处都是这样的模式:

const provider = new ethers.providers.Web3Provider(window.ethereum)
const signer = provider.getSigner()
const contract = new ethers.Contract(address, abi, signer)

看起来很简单,对吧?我照着Viem文档写了第一版:

import { createPublicClient, createWalletClient, custom } from 'viem'
import { mainnet } from 'viem/chains'

const publicClient = createPublicClient({
  chain: mainnet,
  transport: custom(window.ethereum)
})

const walletClient = createWalletClient({
  chain: mainnet,
  transport: custom(window.ethereum)
})

但一运行就报错:window.ethereum可能是undefined。在ethers.js里,我们习惯在组件挂载后再初始化Provider,但Viem的Client设计更倾向于提前创建。更麻烦的是,用户切换网络时,ethers.js的Provider会自动更新,而Viem的Client需要手动重建。

我意识到,不能简单地对等替换。Viem的架构理念不同:它把“读取”和“写入”分离成了PublicClientWalletClient,把链配置和传输层解耦。我需要重新思考整个数据流的设计。

核心实现

1. 设计可动态切换的Client工厂

首先,我需要一个能处理动态链切换的Client管理方案。这里有个坑:Viem的Client创建后,链配置是固定的。用户切换网络时,必须创建新的Client实例。

我的解决方案是创建一个工厂函数,根据当前链ID动态生成Client:

import { createPublicClient, createWalletClient, custom, Chain } from 'viem'
import { mainnet, polygon, arbitrum } from 'viem/chains'

// 链ID到配置的映射
const CHAIN_CONFIGS: Record<number, Chain> = {
  1: mainnet,
  137: polygon,
  42161: arbitrum,
}

export function createClients(chainId: number, ethereum: any) {
  const chain = CHAIN_CONFIGS[chainId]
  
  if (!chain) {
    throw new Error(`Unsupported chainId: ${chainId}`)
  }

  const publicClient = createPublicClient({
    chain,
    transport: custom(ethereum),
  })

  const walletClient = createWalletClient({
    chain,
    transport: custom(ethereum),
  })

  return { publicClient, walletClient }
}

但这样还不够,因为每次切换网络都要重新创建Client,性能开销大。我加了一层缓存:

const clientCache = new Map<string, ReturnType<typeof createClients>>()

export function getCachedClients(chainId: number, ethereum: any) {
  const cacheKey = `${chainId}-${ethereum?.isMetaMask ? 'metamask' : 'generic'}`
  
  if (!clientCache.has(cacheKey)) {
    clientCache.set(cacheKey, createClients(chainId, ethereum))
  }
  
  return clientCache.get(cacheKey)!
}

2. 处理BigNumber和数值转换

在ethers.js里,我们习惯用BigNumber处理大数,然后手动转换。Viem用了bigint原生类型,这本来是好事,但和现有代码的兼容性成了问题。

原来的代码:

import { BigNumber } from 'ethers'
const amount = BigNumber.from('1000000000000000000') // 1 ETH
const formatted = ethers.utils.formatEther(amount)

迁移时,我发现项目里到处是ethers.utils.parseEtherformatEther的调用。Viem的处理方式更统一:

import { parseEther, formatEther } from 'viem'

// 字符串转bigint
const amount = parseEther('1.5') // 1500000000000000000n

// bigint转可读字符串
const readable = formatEther(1500000000000000000n) // '1.5'

但这里有个细节要注意:Viem的parseEther返回的是bigint,不是字符串。如果你需要字符串形式的wei值,得手动转换:

const amountBigInt = parseEther('1.5')
const amountString = amountBigInt.toString() // '1500000000000000000'

我写了一个适配层来平滑迁移:

export function toBigInt(value: string | number | bigint): bigint {
  if (typeof value === 'bigint') return value
  if (typeof value === 'string') {
    // 处理科学计数法
    if (value.includes('e')) {
      return BigInt(Number(value))
    }
    return BigInt(value)
  }
  return BigInt(value)
}

export function fromWei(value: bigint, decimals: number = 18): string {
  const divisor = 10n ** BigInt(decimals)
  const integerPart = value / divisor
  const fractionalPart = value % divisor
  
  if (fractionalPart === 0n) {
    return integerPart.toString()
  }
  
  // 保留足够的小数位
  const fractionStr = fractionalPart.toString().padStart(decimals, '0')
  // 去掉末尾的0
  const trimmed = fractionStr.replace(/0+$/, '')
  
  return `${integerPart}.${trimmed}`
}

3. 合约交互的重构

这是最复杂的部分。原来的合约调用模式是统一的:

const contract = new ethers.Contract(address, abi, signer)
const tx = await contract.deposit(amount, { value: amount })
await tx.wait()

Viem的写法完全不同,而且读写操作要分开处理。我花了半天时间才理清楚:

对于只读操作:

import { readContract } from 'viem/actions'

const result = await publicClient.readContract({
  address: '0x...',
  abi: contractABI,
  functionName: 'balanceOf',
  args: ['0xuser...'],
})

对于写入操作:

import { writeContract } from 'viem/actions'

const hash = await walletClient.writeContract({
  address: '0x...',
  abi: contractABI,
  functionName: 'deposit',
  args: [amount],
  value: amount,
})

// 等待交易确认
const receipt = await publicClient.waitForTransactionReceipt({ hash })

这里有个重要的区别:在ethers.js里,contract.deposit()返回一个Transaction对象,你可以监听它的状态。在Viem里,writeContract直接返回交易哈希,你需要用waitForTransactionReceipt来等待确认。

我创建了一个通用的合约交互Hook:

import { useCallback } from 'react'
import { Address, Hash } from 'viem'

interface ContractCallOptions {
  address: Address
  abi: any[]
  functionName: string
  args?: any[]
  value?: bigint
}

export function useContractCall() {
  const { publicClient, walletClient } = useClients() // 自定义Hook,提供Client
  
  const read = useCallback(async (options: ContractCallOptions) => {
    if (!publicClient) throw new Error('Public client not available')
    
    return publicClient.readContract({
      address: options.address,
      abi: options.abi,
      functionName: options.functionName,
      args: options.args,
    })
  }, [publicClient])
  
  const write = useCallback(async (options: ContractCallOptions): Promise<Hash> => {
    if (!walletClient) throw new Error('Wallet client not available')
    
    return walletClient.writeContract({
      address: options.address,
      abi: options.abi,
      functionName: options.functionName,
      args: options.args,
      value: options.value,
    })
  }, [walletClient])
  
  return { read, write }
}

4. 事件监听的迁移

事件处理是另一个大坑。原来的代码:

contract.on('Deposit', (sender, amount, event) => {
  console.log(`Deposit from ${sender}: ${amount}`)
})

Viem的事件监听更底层,需要自己处理过滤和解析:

import { watchContractEvent } from 'viem/actions'

const unwatch = watchContractEvent(publicClient, {
  address: '0x...',
  abi: contractABI,
  eventName: 'Deposit',
  onLogs: (logs) => {
    logs.forEach((log) => {
      const { args } = log
      console.log(`Deposit from ${args.sender}: ${args.amount}`)
    })
  },
})

// 取消监听
unwatch()

这里要注意的是,watchContractEvent返回的是一个取消监听的函数,不像ethers.js那样有contract.removeAllListeners()。而且,Viem的事件参数是强类型的,这是好事,但需要ABI定义准确。

我写了一个包装函数来处理常见的事件监听模式:

export function useContractEvent(
  address: Address,
  abi: any[],
  eventName: string,
  callback: (args: any) => void
) {
  const { publicClient } = useClients()
  
  useEffect(() => {
    if (!publicClient || !address) return
    
    const unwatch = watchContractEvent(publicClient, {
      address,
      abi,
      eventName,
      onLogs: (logs) => {
        logs.forEach((log) => {
          callback(log.args)
        })
      },
    })
    
    return () => unwatch()
  }, [address, abi, eventName, callback, publicClient])
}

完整代码

下面是一个完整的、可运行的React组件示例,展示了如何使用Viem进行基本的链上交互:

import React, { useState, useEffect } from 'react'
import { createPublicClient, createWalletClient, custom, parseEther, formatEther } from 'viem'
import { mainnet } from 'viem/chains'
import { readContract, writeContract, waitForTransactionReceipt } from 'viem/actions'

// 简单的ERC20 ABI片段
const ERC20_ABI = [
  {
    name: 'balanceOf',
    type: 'function',
    inputs: [{ name: 'owner', type: 'address' }],
    outputs: [{ name: '', type: 'uint256' }],
    stateMutability: 'view',
  },
  {
    name: 'transfer',
    type: 'function',
    inputs: [
      { name: 'to', type: 'address' },
      { name: 'amount', type: 'uint256' },
    ],
    outputs: [{ name: '', type: 'bool' }],
    stateMutability: 'nonpayable',
  },
] as const

function WalletInteraction() {
  const [account, setAccount] = useState<string>('')
  const [balance, setBalance] = useState<bigint>(0n)
  const [publicClient, setPublicClient] = useState<any>(null)
  const [walletClient, setWalletClient] = useState<any>(null)
  
  // 初始化Clients
  useEffect(() => {
    if (window.ethereum) {
      const publicClient = createPublicClient({
        chain: mainnet,
        transport: custom(window.ethereum),
      })
      
      const walletClient = createWalletClient({
        chain: mainnet,
        transport: custom(window.ethereum),
      })
      
      setPublicClient(publicClient)
      setWalletClient(walletClient)
    }
  }, [])
  
  // 连接钱包
  const connectWallet = async () => {
    if (!window.ethereum) {
      alert('请安装MetaMask')
      return
    }
    
    try {
      const [address] = await window.ethereum.request({
        method: 'eth_requestAccounts',
      })
      setAccount(address)
      
      // 查询余额
      if (publicClient) {
        const balance = await publicClient.getBalance({ address })
        setBalance(balance)
      }
    } catch (error) {
      console.error('连接钱包失败:', error)
    }
  }
  
  // 查询ERC20余额
  const queryTokenBalance = async (tokenAddress: string) => {
    if (!publicClient || !account) return
    
    try {
      const balance = await readContract(publicClient, {
        address: tokenAddress as `0x${string}`,
        abi: ERC20_ABI,
        functionName: 'balanceOf',
        args: [account],
      })
      
      console.log('Token balance:', balance)
      return balance
    } catch (error) {
      console.error('查询代币余额失败:', error)
    }
  }
  
  // 发送ETH
  const sendETH = async (to: string, amount: string) => {
    if (!walletClient || !account) return
    
    try {
      const hash = await walletClient.sendTransaction({
        account,
        to: to as `0x${string}`,
        value: parseEther(amount),
      })
      
      console.log('交易哈希:', hash)
      
      // 等待确认
      const receipt = await waitForTransactionReceipt(publicClient, { hash })
      console.log('交易确认:', receipt)
      
      return receipt
    } catch (error) {
      console.error('发送交易失败:', error)
    }
  }
  
  // 转账ERC20
  const transferToken = async (tokenAddress: string, to: string, amount: bigint) => {
    if (!walletClient || !account) return
    
    try {
      const hash = await writeContract(walletClient, {
        address: tokenAddress as `0x${string}`,
        abi: ERC20_ABI,
        functionName: 'transfer',
        args: [to as `0x${string}`, amount],
        account,
      })
      
      console.log('代币转账哈希:', hash)
      return hash
    } catch (error) {
      console.error('代币转账失败:', error)
    }
  }
  
  return (
    <div>
      <h1>Viem钱包交互示例</h1>
      
      {!account ? (
        <button onClick={connectWallet}>连接钱包</button>
      ) : (
        <div>
          <p>已连接: {account}</p>
          <p>余额: {formatEther(balance)} ETH</p>
          
          <button onClick={() => sendETH('0x...', '0.01')}>
            发送0.01 ETH
          </button>
        </div>
      )}
    </div>
  )
}

export default WalletInteraction

踩坑记录

1. window.ethereum的类型问题

报错: Property 'ethereum' does not exist on type 'Window & typeof globalThis' 解决: 需要扩展Window接口,或者在代码中强制类型转换:

declare global {
  interface Window {
    ethereum?: any
  }
}
// 或者
const ethereum = (window as any).ethereum

2. 链切换时Client不更新

现象: 用户切换网络后,交易还是发到原来的链上 原因: Viem的Client创建后链配置是固定的 解决: 监听chainChanged事件,重新创建Client:

window.ethereum?.on('chainChanged', (chainId: string) => {
  const newChainId = parseInt(chainId, 16)
  // 销毁旧的Client,用新chainId创建新的
})

3. BigInt的序列化问题

报错: Do not know how to serialize a BigInt 现象: 尝试将包含bigint的对象存入Redux或通过props传递时报错 解决: 在序列化前转换为字符串:

const serializable = {
  amount: amount.toString(),
  // 其他字段...
}

4. 事件参数类型推断失败

报错: Property 'args' does not exist on type 'Log' 原因: ABI定义不够精确,TypeScript无法推断出args的类型 解决: 使用as const断言确保ABI类型被正确推断:

const ERC20_ABI = [
  // ... 明确定义每个字段的类型
] as const

小结

这次迁移让我深刻体会到,从ethers.js到Viem不只是换API,更是思维模式的转变。Viem的架构更模块化、类型更安全,但需要适应它的“读写分离”和“链配置不可变”的设计理念。最大的收获是:提前设计好Client的生命周期管理,比边写边改要省事得多。下一步我打算深入研究Viem的账户抽象和多链管理,把这些经验用到新项目中。

RainbowKit快速集成多链钱包连接,我如何从“连不上”到“丝滑切换”

背景

上个月,我接手了一个多链DeFi聚合器前端的迭代任务。项目原本只支持以太坊主网,现在产品经理要求快速接入Arbitrum、Polygon和Optimism。核心需求很明确:用户进来,点一个按钮就能连接MetaMask、Coinbase Wallet等主流钱包,并且能在不同链之间无缝切换,查看不同链上的资产和协议。

时间紧,任务重。我评估了一下,自己从零实现一套完整的钱包连接、状态管理、链切换和错误处理逻辑,至少得花上一周,而且后续维护成本高。团队里之前用过wagmi,但主要是基础连接。这次我决定试试RainbowKit,因为它号称是“wagmi的最佳实践封装”,开箱即用,而且UI组件很漂亮。我的目标是在一天内搞定基础的多链连接框架。

问题分析

一开始,我的想法很简单:照着RainbowKit官方文档,安装、配置、把ConnectButton组件一扔,不就完事了?但现实很快给了我一巴掌。

我按照基础教程配好了,按钮是出来了,也能弹出钱包选择框。但第一个问题马上就来了:用户连接后,我需要在应用的其他地方(比如导航栏显示地址、资产页面)获取当前的连接状态和账户信息。我本能地想用wagmi的useAccount等hook,但发现状态有时不同步。点击断开连接后,UI上偶尔还会显示已连接的状态。

第二个问题是链的切换。我配置了多个链,但用户从MetaMask里手动切换了网络(比如从Ethereum切到Polygon),我的应用界面有时感知不到,还是显示旧链的信息,导致后续的合约调用全错在错误的链上。

我意识到,RainbowKit虽然封装了复杂性,但它和底层wagmi的状态流、以及和用户钱包扩展程序的实时通信,需要更细致的配置才能稳定工作。这不是“配完即走”,而是需要理解它们之间如何协同。

核心实现

第一步:项目初始化与依赖安装

首先,我创建了一个新的React + TypeScript项目(如果已有项目,则跳过创建)。RainbowKit需要wagmi作为底层依赖,并且需要配置对应的链信息。

# 创建新项目
npx create-react-app my-web3-app --template typescript
cd my-web3-app

# 安装核心依赖
npm install @rainbow-me/rainbowkit wagmi viem @tanstack/react-query

这里有个关键点:RainbowKit依赖于@tanstack/react-query(旧称react-query)来进行高效的状态管理和缓存。即使你不直接使用它,也必须安装,否则会报错。

第二步:配置Provider与支持的链

这是核心配置环节。我需要在应用的根组件(通常是index.tsxApp.tsx)外包一层RainbowKit和wagmi的Provider。重点在于wagmiConfig的生成,这里需要定义项目支持哪些链。

我决定先支持四个链:Ethereum, Polygon, Arbitrum, Optimism。

// App.tsx
import React from 'react';
import './App.css';
import '@rainbow-me/rainbowkit/styles.css'; // 导入RainbowKit默认样式
import {
  getDefaultConfig,
  RainbowKitProvider,
} from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import {
  mainnet,
  polygon,
  arbitrum,
  optimism,
} from 'wagmi/chains';
import {
  QueryClientProvider,
  QueryClient,
} from '@tanstack/react-query';

// 1. 初始化QueryClient
const queryClient = new QueryClient();

// 2. 配置Wagmi
const config = getDefaultConfig({
  appName: 'MyMultiChainDeFiApp',
  projectId: 'YOUR_PROJECT_ID', // 需要去WalletConnect Cloud申请
  chains: [mainnet, polygon, arbitrum, optimism], // 明确声明支持的链
  ssr: false, // 如果不是Next.js等SSR框架,设为false
});

function App() {
  return (
    // 3. 用Provider层层包裹
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          {/* 你的应用组件 */}
          <div className="App">
            <h1>我的多链DeFi聚合器</h1>
            {/* 其他内容 */}
          </div>
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;

这里有个大坑projectId不能乱填。RainbowKit使用WalletConnect v2协议,这个ID必须从WalletConnect Cloud网站免费注册并创建一个项目来获取。如果随便写一个字符串,钱包连接(尤其是WalletConnect和Coinbase Wallet)会静默失败,控制台错误信息也不明显,我排查了好久。

第三步:使用ConnectButton并获取全局状态

现在,我可以在任何子组件中使用RainbowKit提供的ConnectButton和wagmi的hooks了。我创建了一个Header.tsx组件来放置连接按钮,并展示连接状态。

// components/Header.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useChainId, useSwitchChain } from 'wagmi';

export const Header = () => {
  // 使用wagmi的hooks获取全局状态
  const { address, isConnected, chain } = useAccount();
  const chainId = useChainId();
  const { switchChain } = useSwitchChain();

  return (
    <header>
      <nav>
        <div>我的DeFi应用</div>
        <div>
          {isConnected ? (
            <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
              {/* 显示当前网络 */}
              <span>网络: {chain?.name || `未知 (ID: ${chainId})`}</span>
              {/* 显示缩短的地址 */}
              <span>
                {address?.slice(0, 6)}...{address?.slice(-4)}
              </span>
              {/* RainbowKit提供的完整功能按钮 */}
              <ConnectButton showBalance={false} />
              {/* 一个自定义的链切换示例 */}
              <button onClick={() => switchChain({ chainId: polygon.id })}>
                切换到Polygon
              </button>
            </div>
          ) : (
            <ConnectButton />
          )}
        </div>
      </nav>
    </header>
  );
};

注意这个细节useAccountuseChainId等hook的状态,与ConnectButton组件内部的状态是自动同步的,因为它们共享同一个wagmi配置。这就是为什么我们可以在应用任何地方可靠地获取连接信息。ConnectButton本身已经包含了连接、切换钱包、切换网络、查看详情、断开连接等所有功能的UI和逻辑。

第四步:处理链切换与状态同步

为了让应用能实时响应用户在钱包里手动切换网络的操作,我需要监听链的变化并更新UI。wagmi的useAccount返回的chain对象,以及useChainId hook,都是响应式的。但为了在链切换时执行一些副作用(比如更新合约实例、重新获取链上数据),我使用了useEffect

// components/AssetDashboard.tsx
import { useEffect } from 'react';
import { useAccount, useChainId } from 'wagmi';

export const AssetDashboard = () => {
  const { chain, isConnected } = useAccount();
  const chainId = useChainId();

  useEffect(() => {
    if (!isConnected) return;

    console.log(`链已切换至: ${chain?.name} (ID: ${chainId})`);
    
    // 这里可以执行链切换后的副作用:
    // 1. 更新当前链的RPC Provider
    // 2. 更新合约实例的地址(如果不同链合约地址不同)
    // 3. 重新获取该链上的用户资产数据
    // 4. 更新UI上关于链的提示信息

    // 例如,重新获取资产
    fetchAssetsForChain(chainId);

  }, [chainId, isConnected, chain]); // 依赖chainId,当它变化时触发

  const fetchAssetsForChain = async (currentChainId: number) => {
    // 模拟根据链ID获取资产的函数
    console.log(`获取链 ${currentChainId} 上的资产...`);
    // ... 实际的数据获取逻辑
  };

  return (
    <div>
      <h2>资产总览</h2>
      <p>当前网络: <strong>{chain?.name || '未连接'}</strong></p>
      {/* 资产列表 */}
    </div>
  );
};

这里有个坑chain对象可能为undefined(例如钱包连接了但未授权任何账户,或者是一些边缘情况)。所以在使用chain.namechain.id时,最好使用可选链操作符?.或做空值判断,否则会导致页面渲染错误。

完整代码

以下是一个简化但可运行的核心集成示例,将所有关键部分放在一起。

// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
// App.tsx
import './App.css';
import '@rainbow-me/rainbowkit/styles.css';
import {
  getDefaultConfig,
  RainbowKitProvider,
} from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import {
  mainnet,
  polygon,
  arbitrum,
  optimism,
} from 'wagmi/chains';
import {
  QueryClientProvider,
  QueryClient,
} from '@tanstack/react-query';
import { Header } from './components/Header';
import { AssetDashboard } from './components/AssetDashboard';

const queryClient = new QueryClient();

// 注意:请替换为你在 WalletConnect Cloud 申请的 projectId
const projectId = 'YOUR_WALLETCONNECT_PROJECT_ID_HERE';

const config = getDefaultConfig({
  appName: 'MultiChainDemo',
  projectId: projectId,
  chains: [mainnet, polygon, arbitrum, optimism],
  ssr: false,
});

function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <div className="App">
            <Header />
            <main>
              <AssetDashboard />
              {/* 你的其他页面组件 */}
            </main>
          </div>
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;
// components/Header.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount } from 'wagmi';

export const Header = () => {
  const { isConnected, address, chain } = useAccount();

  return (
    <header style={{ padding: '1rem', borderBottom: '1px solid #ccc', display: 'flex', justifyContent: 'space-between' }}>
      <h1>多链DeFi演示</h1>
      <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
        {isConnected && (
          <>
            <div>
              网络: <strong>{chain?.name}</strong>
            </div>
            <div>
              地址: <code>{address?.slice(0, 8)}...{address?.slice(-6)}</code>
            </div>
          </>
        )}
        <ConnectButton />
      </div>
    </header>
  );
};
// components/AssetDashboard.tsx
import { useEffect } from 'react';
import { useAccount, useChainId } from 'wagmi';

export const AssetDashboard = () => {
  const { chain, isConnected } = useAccount();
  const chainId = useChainId();

  useEffect(() => {
    if (!isConnected) {
      console.log('钱包未连接');
      return;
    }
    // 当链ID变化时,执行数据更新逻辑
    console.log(`[副作用] 检测到链变化,当前链ID: ${chainId}, 名称: ${chain?.name}`);
    // 在实际项目中,这里应调用一个函数来更新该链的资产数据
  }, [chainId, isConnected, chain]);

  return (
    <div style={{ padding: '2rem' }}>
      <h2>资产仪表板</h2>
      <p>这个组件会监听链切换。打开控制台查看日志。</p>
      <div>
        <p><strong>连接状态:</strong> {isConnected ? '已连接' : '未连接'}</p>
        <p><strong>当前网络:</strong> {chain?.name || 'N/A'}</p>
        <p><strong>链ID:</strong> {chainId || 'N/A'}</p>
      </div>
    </div>
  );
};

踩坑记录

  1. WalletConnect ProjectId 无效导致静默失败:这是最大的坑。我一开始随便写了个字符串,MetaMask能连(因为它不走WalletConnect),但Coinbase Wallet和WalletConnect二维码死活没反应,控制台也没有明显错误。后来在RainbowKit的GitHub issue里看到,必须去WalletConnect Cloud创建项目获取真实ID。解决后一切正常。
  2. 链ID不匹配导致切换失败:我自定义了一个测试链,它的id我设成了12345。当我调用switchChain({ chainId: 12345 })时,钱包弹窗提示切换,但RainbowKit内部状态没更新。后来发现,getDefaultConfigchains数组必须包含这个链的定义,并且id要和钱包里添加的网络ID完全一致。本质是RainbowKit/wagmi需要知道你打算切换到的链的详细信息(RPC URL、区块浏览器等)。
  3. Hydration错误(Next.js场景):在Next.js项目中,如果SSR开启,需要在getDefaultConfig里设置ssr: true,并且确保与钱包相关的组件只在客户端渲染(用useEffecttypeof window !== 'undefined'判断),否则会因为服务端和客户端初始渲染内容不一致而报错。虽然我这次是Create React App,但这是常见的坑。
  4. 样式冲突:RainbowKit会注入一些全局样式,如果和你项目的现有CSS(比如用了CSS-in-JS库或重置样式表)冲突,可能会导致弹窗位置错乱或样式怪异。解决方法是检查元素,用更高特异性的CSS规则覆盖,或者利用RainbowKit提供的主题定制功能来适配。

小结

通过这次集成,我最大的收获是:RainbowKit + wagmi 确实能极大加速Web3前端连接层的开发,但“开箱即用”不等于“无需理解”。清晰配置支持的链、妥善管理WalletConnect ProjectId、理解状态hook的响应式原理,是保证多链连接稳定丝滑的关键。下一步,我可以深入研究RainbowKit的主题定制,让UI完全融入项目设计,并探索如何与更复杂的多链合约读写逻辑结合。

Next.js 14 + wagmi v2 构建 NFT 市场:从列表渲染到链上交易的全链路实践

背景

上个月,我接手了一个基于 Polygon 链的 NFT 交易市场前端重构项目。原项目是用 Create React App 搭的,状态管理比较混乱,读取 NFT 列表和钱包交互的代码耦合严重。这次我决定用 Next.js 14(App Router)和 wagmi v2 重写,目标是构建一个响应快、用户体验好、且易于维护的前端。

项目核心需求很简单:1. 从智能合约中读取正在出售的 NFT 列表并展示;2. 用户连接钱包后可以购买 NFT。听起来不复杂,但实际开发中,如何在 App Router 的 Server/Client 组件架构下优雅地管理 Web3 数据流,如何可靠地处理购买交易并同步 UI 状态,这些问题让我踩了不少坑。

问题分析

一开始,我按照传统思路,打算在页面组件(Server Component)里直接用 viem 的公共客户端读取合约数据。但很快发现两个问题:第一,合约的 tokenURI 返回的是指向 IPFS 或 HTTP 的链接,需要在客户端解析;第二,NFT 的当前价格和出售状态可能随时变化,需要实时性。

我的第一个方案是在 useEffect 里调用合约,但这样无法利用 Next.js 的服务器端渲染优势,首屏加载慢。接着尝试用 wagmiuseReadContract,但它在 Server Component 里不能直接用。我意识到,问题的核心是如何在 Next.js 14 的架构下,合理分割服务端静态数据获取和客户端动态链上交互

经过排查,我决定采用这样的架构:1. 服务端用简单的 RPC 调用获取 NFT 的基础 ID 列表;2. 客户端用 wagmi 订阅合约事件并获取动态数据(如价格、是否已售);3. 购买交易使用 wagmiuseWriteContract 配合状态监听来更新 UI。

核心实现

1. 项目初始化与依赖配置

首先,我用 pnpm create next-app@latest 创建了项目,选择了 TypeScript 和 Tailwind CSS。然后安装核心依赖:

pnpm add viem wagmi @rainbow-me/rainbowkit
pnpm add -D @types/node

这里有个坑wagmi v2 对 TypeScript 版本和 Node.js 类型有要求,如果遇到类型错误,可能需要检查 tsconfig.json 中的 lib 字段是否包含 DOMES2020

接下来,创建 app/providers.tsx 文件来配置 wagmiRainbowKit 的 Provider。这是整个应用 Web3 功能的基石。

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider, createConfig, http } from 'wagmi';
import { polygon } from 'wagmi/chains';
import { RainbowKitProvider } from '@rainbow-me/rainbowkit';
import '@rainbow-me/rainbowkit/styles.css';

// 1. 设置查询客户端
const queryClient = new QueryClient();

// 2. 创建 wagmi 配置
const config = createConfig({
  chains: [polygon], // 我们主要用 Polygon 链
  transports: {
    [polygon.id]: http('https://polygon-rpc.com'), // 公共 RPC,生产环境建议用 Infura 或 Alchemy
  },
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>{children}</RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

然后在 app/layout.tsx 中包裹这个 Provider。注意这个细节Providers 组件必须标记为 'use client',但 layout 本身可以是 Server Component。

2. 服务端获取 NFT 基础列表

我创建了一个简单的服务端组件 app/page.tsx 来获取 NFT 列表的“骨架”。这里我只获取 NFT 的 token ID,因为元数据(图片、名称)和动态数据(价格)需要客户端获取。

// app/page.tsx
import { createPublicClient, http } from 'viem';
import { polygon } from 'viem/chains';
import NFTMarketClient from './components/NFTMarketClient';

// 这是一个模拟的 NFT 市场合约 ABI 片段
const MARKET_ABI = [
  {
    inputs: [],
    name: 'getAllListedTokens',
    outputs: [{ name: '', type: 'uint256[]' }],
    stateMutability: 'view',
    type: 'function',
  },
] as const;

const CONTRACT_ADDRESS = '0x...'; // 你的合约地址

export default async function HomePage() {
  // 在服务端创建公共客户端
  const client = createPublicClient({
    chain: polygon,
    transport: http('https://polygon-rpc.com'),
  });

  let tokenIds: bigint[] = [];
  try {
    // 调用合约读取上架的 NFT ID 数组
    const data = await client.readContract({
      address: CONTRACT_ADDRESS,
      abi: MARKET_ABI,
      functionName: 'getAllListedTokens',
    });
    tokenIds = data as bigint[];
  } catch (error) {
    console.error('Failed to fetch token IDs:', error);
    // 生产环境应有更完善的错误处理
  }

  // 将 BigInt 转换为字符串,因为 React 的 props 需要可序列化
  const initialTokenIds = tokenIds.map(id => id.toString());

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-8">NFT Marketplace</h1>
      {/* 将初始数据传递给客户端组件 */}
      <NFTMarketClient initialTokenIds={initialTokenIds} />
    </div>
  );
}

这样做的好处是,即使用户没装钱包,首屏也能看到 NFT 的列表框架,提升感知速度。

3. 客户端组件:数据订阅与展示

真正的重头戏在客户端组件 app/components/NFTMarketClient.tsx。这里需要完成三件事:1. 用 useReadContract 并行获取每个 NFT 的详情;2. 展示列表;3. 处理购买。

首先,定义完整的合约 ABI:

// app/components/NFTMarketClient.tsx
'use client';

import { useReadContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { ConnectButton } from '@rainbow-me/rainbowkit';

const FULL_MARKET_ABI = [
  // 列出 NFT 的函数(省略)
  // ...
  // 读取列表的函数
  {
    inputs: [],
    name: 'getAllListedTokens',
    outputs: [{ name: '', type: 'uint256[]' }],
    stateMutability: 'view',
    type: 'function',
  },
  // 获取某个 NFT 列表信息的函数
  {
    inputs: [{ name: 'tokenId', type: 'uint256' }],
    name: 'getListing',
    outputs: [
      { name: 'seller', type: 'address' },
      { name: 'price', type: 'uint256' },
      { name: 'isActive', type: 'bool' },
    ],
    stateMutability: 'view',
    type: 'function',
  },
  // 购买 NFT 的函数
  {
    inputs: [{ name: 'tokenId', type: 'uint256' }],
    name: 'buyToken',
    outputs: [],
    stateMutability: 'payable',
    type: 'function',
  },
  // 列表更新事件
  {
    type: 'event',
    name: 'TokenListed',
    inputs: [
      { indexed: true, name: 'tokenId', type: 'uint256' },
      { indexed: false, name: 'seller', type: 'address' },
      { indexed: false, name: 'price', type: 'uint256' },
    ],
  },
  {
    type: 'event',
    name: 'TokenSold',
    inputs: [
      { indexed: true, name: 'tokenId', type: 'uint256' },
      { indexed: false, name: 'buyer', type: 'address' },
    ],
  },
] as const;

然后,在组件内部,我们需要为每个 tokenId 调用 getListing 来获取价格和状态。这里有个性能坑:如果循环调用 useReadContract,会导致过多的请求。我采用了 Promise.all 配合 wagmiclient.readContract 来批量读取。

// 在组件内部
import { useEffect, useState } from 'react';
import { usePublicClient } from 'wagmi';

interface NFTListing {
  tokenId: string;
  price: bigint | null;
  isActive: boolean | null;
  seller: `0x${string}` | null;
}

export default function NFTMarketClient({ initialTokenIds }: { initialTokenIds: string[] }) {
  const [listings, setListings] = useState<NFTListing[]>([]);
  const publicClient = usePublicClient();

  // 批量获取列表详情
  useEffect(() => {
    const fetchListings = async () => {
      if (!publicClient || initialTokenIds.length === 0) return;

      const promises = initialTokenIds.map(async (tokenId) => {
        try {
          const data = await publicClient.readContract({
            address: CONTRACT_ADDRESS,
            abi: FULL_MARKET_ABI,
            functionName: 'getListing',
            args: [BigInt(tokenId)],
          }) as [string, bigint, boolean]; // 对应 seller, price, isActive

          return {
            tokenId,
            seller: data[0] as `0x${string}`,
            price: data[1],
            isActive: data[2],
          };
        } catch (error) {
          console.error(`Failed to fetch listing for token ${tokenId}:`, error);
          return {
            tokenId,
            seller: null,
            price: null,
            isActive: null,
          };
        }
      });

      const results = await Promise.all(promises);
      setListings(results);
    };

    fetchListings();
  }, [initialTokenIds, publicClient]);

4. 实现购买功能与状态同步

购买功能需要处理交易发送、等待确认和 UI 状态更新。wagmi v2 的 useWriteContractuseWaitForTransactionReceipt 钩子让这个过程清晰了很多。

// 继续在组件内部
const CONTRACT_ADDRESS = '0x...';

export default function NFTMarketClient({ initialTokenIds }: { initialTokenIds: string[] }) {
  // ... 之前的 state 和 effect

  const { writeContract, data: hash, isPending: isWriting } = useWriteContract();
  const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({
    hash,
  });

  const handleBuy = (tokenId: string, price: bigint) => {
    if (!price) return;
    
    writeContract({
      address: CONTRACT_ADDRESS,
      abi: FULL_MARKET_ABI,
      functionName: 'buyToken',
      args: [BigInt(tokenId)],
      value: price, // 支付金额
    });
  };

  // 交易确认成功后,更新本地状态
  useEffect(() => {
    if (isConfirmed && hash) {
      // 这里可以添加更精细的逻辑,比如根据交易日志更新特定的 NFT 状态
      // 简单起见,我们重新获取所有列表
      setListings(prev => prev.map(item => 
        item.price === null ? { ...item, isActive: false } : item
      ));
      alert('Purchase successful!');
    }
  }, [isConfirmed, hash]);

  return (
    <div>
      <div className="flex justify-end mb-4">
        <ConnectButton />
      </div>
      
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {listings.map((item) => (
          <div key={item.tokenId} className="border rounded-lg p-4 shadow">
            <div className="h-48 bg-gray-200 mb-4 rounded">NFT #{item.tokenId}</div>
            <div className="mb-2">
              <span className="font-semibold">Price: </span>
              {item.price ? `${Number(item.price) / 1e18} MATIC` : 'Loading...'}
            </div>
            <button
              onClick={() => item.price && handleBuy(item.tokenId, item.price)}
              disabled={!item.isActive || isWriting || isConfirming}
              className="w-full bg-blue-600 text-white py-2 rounded disabled:bg-gray-400"
            >
              {isWriting || isConfirming ? 'Processing...' : 'Buy Now'}
            </button>
            {!item.isActive && (
              <p className="text-red-500 text-sm mt-2">This NFT is no longer for sale.</p>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

注意这个细节value 字段是 payable 函数的关键,它指定了随交易发送的 Native Token 数量。这里我们传递的是 NFT 的标价。

完整代码

由于篇幅限制,这里提供最核心的整合版本,省略了部分样式和错误处理的细节。关键部分都已包含。

// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'NFT Marketplace',
  description: 'A simple NFT marketplace built with Next.js and wagmi',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
// app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

其他文件 (providers.tsx, page.tsx, components/NFTMarketClient.tsx) 的代码已在前文分步给出,组合起来即可运行。记得替换 CONTRACT_ADDRESS 为你自己的合约地址,并完善 ABI。

踩坑记录

  1. BigInt 序列化错误:在 Server Component 中获取的 bigint 类型无法直接通过 props 传递给 Client Component。Next.js 会报序列化错误。解决:在服务端转换为字符串 id.toString(),在客户端需要时再转回 BigInt

  2. useReadContract 在循环中性能低下:最初我在 listings.map 里直接为每个 NFT 调用 useReadContract,导致同时发起数十个请求,页面卡顿。解决:改用 useEffect 配合 publicClient.readContract 进行批量读取,只触发一次状态更新。

  3. 交易成功后 UI 状态不同步:用户购买成功后,列表中的 NFT 状态没有立即更新。虽然链上状态已变,但其他用户刷新前仍能看到“购买”按钮。解决:利用 useWaitForTransactionReceiptisSuccess 状态触发本地数据重新获取或乐观更新。更完善的方案是监听合约的 TokenSold 事件。

  4. RainbowKit 主题与 Next.js 冲突:在 layout.tsx 中引入 RainbowKit 的 CSS 文件时,如果顺序不对,会导致 Tailwind 样式被覆盖。解决:确保在 providers.tsx 中先导入 @rainbow-me/rainbowkit/styles.css,再在 globals.css 中定义自定义样式。

小结

这次重构让我深刻体会到,在 Next.js App Router 中构建 Web3 应用,核心是明确数据获取的边界:服务端获取静态或准静态数据,客户端管理动态和交互状态。wagmi v2 的钩子与 TanStack Query 的集成让缓存和状态管理变得直观,而 viem 的强类型 ABI 支持大大减少了运行时错误。

这个方案还可以继续优化,比如实现 NFT 元数据(图片、名称)的获取和缓存、使用 useMemo 优化计算、以及集成更复杂的事件监听系统来实现真正的实时更新。希望我的踩坑记录能帮你绕过这些弯路。

在NFT项目中集成IPFS:从Pinata上传到前端展示的完整踩坑指南

背景

上个月,我接了一个生成式NFT项目的活。简单说,就是让用户在前端组合不同的图层(背景、角色、道具),生成一张独特的图片,然后把它铸造成NFT。功能做起来挺顺,直到卡在最后一步:怎么把用户生成的图片和对应的元数据(比如名称、描述、属性)存起来?

直接存服务器肯定不行,项目一停,所有NFT就成“死图”了。必须用去中心化存储。IPFS(星际文件系统)是标准答案,文件上传后会得到一个唯一的CID(内容标识符),只要网络上有一个节点存着这份文件,它就能被访问。但问题来了,怎么让文件在IPFS网络上“钉住”(Pin),确保它不因为没人访问而被垃圾回收?自己搭节点维护成本太高,所以得找个靠谱的“钉住”服务。一番调研后,我选了 Pinata。它提供了简单的API和不错的免费额度,正好适合这个项目。

我的任务很明确:在前端实现用户图片上传到IPFS(通过Pinata),拿到CID,然后构造出符合ERC-721标准的元数据JSON文件,再把这个JSON文件也上传到IPFS,最后将JSON的CID作为tokenURI传给智能合约。听起来链路清晰,但实现时每一步都遇到了意想不到的坑。

问题分析

我最开始的思路特别“直男”:

  1. 前端用fetchaxios把图片File对象直接POST到Pinata的API。
  2. 拿到返回的CID,拼接到ipfs://后面。
  3. 用这个链接去铸币。

结果第一步就失败了。浏览器直接报了CORS错误。我查了Pinata文档,发现他们的上传API确实对前端直接调用不太友好,主要推荐用他们的SDK或者通过服务端中转。但我不想为了这个功能再搭个后端,增加复杂度和成本。

然后我尝试用他们的SDK @pinata/sdk。在React项目里装好,导入,调用,结果在构建时直接报错——这个SDK严重依赖Node.js的核心模块(比如fs, path),在前端浏览器环境里根本跑不起来。这条路也堵死了。

这时候我才意识到,从前端安全、直接地上传文件到IPFS,需要一种专门为浏览器设计的方法。我得重新规划技术路线。

核心实现

1. 放弃官方SDK,改用更轻量的上传方式

既然@pinata/sdk行不通,我转而研究Pinata的API文档。他们提供了一个名为 pinFileToIPFS 的接口,支持通过multipart/form-data格式上传文件。关键点在于认证:需要在请求头里带上一个JWT格式的Bearer Token

这个Token需要在Pinata官网的开发者面板里生成,是专为前端设计的,权限可以限制为仅上传(相比API Key更安全)。有了这个思路,我决定直接用浏览器的FormData API配合fetch来上传。

这里有个大坑:Pinata的pinFileToIPFS接口一次只能上传一个文件。但我的需求里,用户最终可能同时上传图片和元数据JSON文件(两步上传)。不过,对于单张图片上传,这个接口足够了。

// utils/pinata.ts
const PINATA_JWT = process.env.NEXT_PUBLIC_PINATA_JWT; // 注意:前端环境变量需以NEXT_PUBLIC_开头(如果你用Next.js)

export const uploadFileToIPFS = async (file: File): Promise<string> => {
  // 1. 构建FormData对象
  const formData = new FormData();
  formData.append('file', file);

  // 2. 添加可选的元数据,方便在Pinata面板管理。这里我把文件名加进去。
  const metadata = JSON.stringify({
    name: file.name,
  });
  formData.append('pinataMetadata', metadata);

  // 3. 设置Pinata的选项,这里我们设置不重复上传相同内容(节省空间)
  const options = JSON.stringify({
    cidVersion: 0,
  });
  formData.append('pinataOptions', options);

  try {
    const res = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
      method: 'POST',
      headers: {
        // 关键:使用Bearer Token认证
        Authorization: `Bearer ${PINATA_JWT}`,
      },
      body: formData,
    });

    const data = await res.json();
    if (!res.ok) {
      throw new Error(`Pinata上传失败: ${data.error?.details || data.error}`);
    }
    // 返回IPFS CID (Content Identifier)
    return data.IpfsHash;
  } catch (error) {
    console.error('上传文件到IPFS失败:', error);
    throw error;
  }
};

2. 构建并上传NFT元数据JSON

拿到图片的CID(假设为imageCid)后,下一步是构建NFT的元数据。这是一个符合特定格式的JSON对象,ERC-721标准通常期望它包含namedescriptionimageattributes等字段。其中,image字段的值应该是图片的URI。

这里有个至关重要的细节:image字段的URI格式。 我一开始直接用了ipfs://${imageCid}。后来发现,很多钱包和平台(如OpenSea)对这种原生IPFS URI的支持并不一致。更通用、更推荐的做法是使用经过网关代理的HTTPS链接,比如https://gateway.pinata.cloud/ipfs/${imageCid} 或公共网关 https://ipfs.io/ipfs/${imageCid}。为了确保最大兼容性,我决定在元数据里存储网关链接。

// utils/pinata.ts
export interface NFTMetadata {
  name: string;
  description: string;
  image: string; // 使用HTTPS网关链接
  attributes: Array<{
    trait_type: string;
    value: string | number;
  }>;
}

export const uploadMetadataToIPFS = async (metadata: NFTMetadata): Promise<string> => {
  // 将元数据对象转换为JSON字符串
  const jsonString = JSON.stringify(metadata);

  // 创建一个File对象,代表我们的元数据JSON“文件”
  const metadataFile = new File([jsonString], 'metadata.json', { type: 'application/json' });

  // 复用上面的上传函数,将这个“文件”上传到IPFS
  const metadataCid = await uploadFileToIPFS(metadataFile);
  return metadataCid;
};

3. 在前端React组件中串联整个流程

现在有了上传图片和上传元数据两个工具函数,我需要在用户交互的组件里把它们串起来。场景是:用户点击“生成并铸造”按钮后,前端合成图片(得到一个Blob或DataURL),然后执行上传流程。

// components/MintButton.tsx
import React, { useState } from 'react';
import { uploadFileToIPFS, uploadMetadataToIPFS, NFTMetadata } from '../utils/pinata';
import { useContractWrite } from 'wagmi'; // 假设使用wagmi与合约交互
import abi from '../abis/MyNFT.json';

const MintButton: React.FC = () => {
  const [isMinting, setIsMinting] = useState(false);
  const { writeAsync: mint } = useContractWrite({
    address: '0xYourContractAddress',
    abi: abi,
    functionName: 'safeMint',
  });

  const handleMint = async () => {
    setIsMinting(true);
    try {
      // 1. 假设这是用户生成的图片Blob
      const imageBlob = await generateUserImage(); // 你的图片生成函数
      const imageFile = new File([imageBlob], 'nft-image.png', { type: 'image/png' });

      // 2. 上传图片到IPFS
      console.log('正在上传图片到IPFS...');
      const imageCid = await uploadFileToIPFS(imageFile);
      const imageUrl = `https://gateway.pinata.cloud/ipfs/${imageCid}`;
      console.log('图片上传成功,URL:', imageUrl);

      // 3. 构建并上传元数据
      const metadata: NFTMetadata = {
        name: '我的生成式NFT #1',
        description: '这是一个由用户生成的独特NFT。',
        image: imageUrl, // 使用网关链接!
        attributes: [
          { trait_type: '背景', value: '星空' },
          { trait_type: '角色', value: '战士' },
        ],
      };
      console.log('正在上传元数据到IPFS...');
      const metadataCid = await uploadMetadataToIPFS(metadata);
      // 构造最终传给合约的tokenURI。这里我选择将网关链接存储到链上,确保任何地方都能直接读取。
      const tokenURI = `https://gateway.pinata.cloud/ipfs/${metadataCid}`;
      console.log('元数据上传成功,tokenURI:', tokenURI);

      // 4. 调用智能合约的mint函数
      console.log('正在调用合约进行铸造...');
      const tx = await mint({
        args: [tokenURI], // 将tokenURI作为参数传入
      });
      await tx.wait();
      console.log('NFT铸造成功!');

    } catch (error) {
      console.error('铸造过程失败:', error);
      alert(`铸造失败: ${error.message}`);
    } finally {
      setIsMinting(false);
    }
  };

  return (
    <button onClick={handleMint} disabled={isMinting}>
      {isMinting ? '铸造中...' : '生成并铸造NFT'}
    </button>
  );
};

// 模拟图片生成函数
async function generateUserImage(): Promise<Blob> {
  // 这里应该是你的实际图片合成逻辑,例如用canvas绘图
  // 返回一个Blob对象
  const canvas = document.createElement('canvas');
  canvas.width = 500;
  canvas.height = 500;
  const ctx = canvas.getContext('2d');
  // ... 绘图操作
  return new Promise((resolve) => {
    canvas.toBlob((blob) => resolve(blob!), 'image/png');
  });
}

export default MintButton;

完整代码

以下是一个更完整、独立的工具函数文件示例,包含了错误处理的增强和类型定义:

// lib/ipfs.ts
export interface PinataResponse {
  IpfsHash: string;
  PinSize: number;
  Timestamp: string;
}

export interface NFTMetadata {
  name: string;
  description: string;
  image: string;
  external_url?: string;
  attributes: Array<{
    trait_type: string;
    value: string | number;
    display_type?: string;
  }>;
}

const PINATA_GATEWAY = 'https://gateway.pinata.cloud';
const PINATA_UPLOAD_URL = 'https://api.pinata.cloud/pinning/pinFileToIPFS';

/**
 * 上传任意文件到IPFS (通过Pinata)
 * @param file 要上传的File对象
 * @returns 文件的CID (IpfsHash)
 */
export const uploadToIPFS = async (file: File): Promise<string> => {
  // 环境变量检查
  const pinataJwt = process.env.NEXT_PUBLIC_PINATA_JWT;
  if (!pinataJwt) {
    throw new Error('缺少Pinata JWT环境变量配置');
  }

  const formData = new FormData();
  formData.append('file', file);

  // 添加元数据帮助识别
  const pinataMetadata = JSON.stringify({
    name: `Upload_${file.name}`,
  });
  formData.append('pinataMetadata', pinataMetadata);

  // 设置CID版本为0(默认,更广泛兼容)
  const pinataOptions = JSON.stringify({
    cidVersion: 0,
  });
  formData.append('pinataOptions', pinataOptions);

  const response = await fetch(PINATA_UPLOAD_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${pinataJwt}`,
    },
    body: formData,
  });

  const data: PinataResponse & { error?: any } = await response.json();

  if (!response.ok) {
    const errorMsg = data.error?.details || data.error?.message || `HTTP ${response.status}`;
    throw new Error(`IPFS上传失败: ${errorMsg}`);
  }

  return data.IpfsHash;
};

/**
 * 上传NFT元数据到IPFS
 * @param metadata NFT元数据对象
 * @returns 元数据JSON文件的CID
 */
export const uploadNFTMetadata = async (metadata: NFTMetadata): Promise<string> => {
  const jsonString = JSON.stringify(metadata, null, 2); // 美化输出,方便调试
  const metadataFile = new File([jsonString], 'metadata.json', {
    type: 'application/json',
  });

  const metadataCid = await uploadToIPFS(metadataFile);
  return metadataCid;
};

/**
 * 根据CID生成Pinata网关URL
 * @param cid 文件CID
 * @returns 完整的网关访问URL
 */
export const getPinataGatewayUrl = (cid: string): string => {
  return `${PINATA_GATEWAY}/ipfs/${cid}`;
};

/**
 * 完整的NFT铸造预处理流程
 * 1. 上传图片
 * 2. 构建元数据
 * 3. 上传元数据
 * @param imageFile 图片文件
 * @param metadataBase 不包含image字段的基础元数据
 * @returns 最终用于合约的tokenURI (网关链接)
 */
export const prepareNFTForMinting = async (
  imageFile: File,
  metadataBase: Omit<NFTMetadata, 'image'>
): Promise<{ tokenURI: string; imageUrl: string }> => {
  // 1. 上传图片
  console.log('📤 上传图片中...');
  const imageCid = await uploadToIPFS(imageFile);
  const imageUrl = getPinataGatewayUrl(imageCid);
  console.log('✅ 图片上传成功:', imageUrl);

  // 2. 构建完整元数据
  const fullMetadata: NFTMetadata = {
    ...metadataBase,
    image: imageUrl, // 使用网关链接
  };

  // 3. 上传元数据
  console.log('📤 上传元数据中...');
  const metadataCid = await uploadNFTMetadata(fullMetadata);
  const tokenURI = getPinataGatewayUrl(metadataCid);
  console.log('✅ 元数据上传成功,tokenURI:', tokenURI);

  return { tokenURI, imageUrl };
};

踩坑记录

  1. CORS错误与SDK环境不匹配:这是开头最大的拦路虎。直接调用Pinata API遇到CORS,用官方Node.js SDK又无法在浏览器运行。解决方案:仔细阅读API文档,发现支持前端JWT Token认证的pinFileToIPFS接口,并改用FormData进行multipart/form-data格式的上传。

  2. image字段的URI格式兼容性问题:最初使用ipfs://协议头,在部分钱包内显示为空白。解决方案:在存储到元数据image字段时,统一使用Pinata或公共IPFS网关的HTTPS链接(如https://gateway.pinata.cloud/ipfs/${cid}),极大提升了跨平台的显示成功率。

  3. 上传大文件超时或失败:用户生成的图片分辨率高时,文件可能较大,上传过程中可能失败。解决方案:在前端实现上传进度提示(通过axiosonUploadProgressfetchReadableStream可以做到,但上述示例未展开),并考虑在UI上设置文件大小限制。对于极端情况,可以提示用户或考虑分片上传,但Pinata免费版有单文件大小限制,需要注意。

  4. 元数据JSON格式错误导致OpenSea解析失败:一开始attributes里的value用了复杂对象,或者JSON字符串里有非法字符。解决方案:严格遵循OpenSea等主流市场的元数据标准,确保value是字符串或数字。在上传前用JSON.stringifyJSON.parse做一次校验,确保格式正确。

小结

这次集成让我彻底搞懂了从前端到IPFS的“最后一公里”:关键在于选择正确的API接口(Pinata的pinFileToIPFS)、使用安全的认证方式(JWT Token)、以及为最大兼容性始终使用HTTPS网关链接。下一步可以探索更去中心化的方案,比如用ipfs-http-client直接连接公共网关或自己的节点,或者集成Arweave来做真正永久的存储。

从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑实录

背景

上个月,我接手了一个新的 DeFi 项目前端开发。第一个核心功能就是用户钱包连接。团队技术栈是 React + TypeScript,对于 Web3 交互,我们选择了老牌且功能强大的 ethers.js 库。我心想:“连接 MetaMask 嘛,官方文档例子那么多,还不是手到擒来?” 于是,我复制了一段最常见的示例代码,准备十分钟搞定这个功能。然而,现实很快给了我一个教训——从“连接”到“稳定可用”之间,隔着一整条满是坑的沟。

问题分析

我最开始的思路非常简单:检查 window.ethereum 是否存在,如果存在,就用 ethers.providers.Web3Provider 包装它,然后调用 provider.send('eth_requestAccounts', []) 来触发 MetaMask 的授权弹窗。代码跑起来,在已经安装了 MetaMask 的浏览器里,第一次点击确实弹窗了,连接成功了。

但问题接踵而至:

  1. 刷新页面后,连接状态丢失:用户需要重新点击连接并授权,体验极差。
  2. 用户切换了 MetaMask 账户:前端界面上的地址没有自动更新。
  3. 用户切换了网络(比如从以太坊主网切换到 Goerli 测试网):我们的 DApp 需要感知到这个变化,并可能提示用户切换回目标网络。
  4. 用户根本没有安装 MetaMask:页面直接报错,白屏。

最初的代码只处理了“发起连接”这个单一动作,完全没考虑 Web3 应用是“动态的”、“有状态的”。我意识到,我需要构建的不是一个“连接按钮”,而是一个完整的“钱包连接状态管理器”。它需要监听区块链提供者的各种事件,并将状态同步到 React 组件中。

核心实现

第一步:封装一个健壮的钱包连接钩子

我决定创建一个自定义 React Hook useWallet 来集中管理所有钱包状态。首先,要安全地获取 window.ethereum 对象。这里就有第一个坑:TypeScript 不知道 window.ethereum 的类型

// types/global.d.ts
interface Window {
  ethereum?: any; // 为了快速开发,可以先设为 any,更严谨的做法是导入 MetaMask 的 EIP-1193 类型
}

// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

declare global {
  interface Window {
    ethereum?: any;
  }
}

export const useWallet = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  // 初始化:检查是否已授权
  useEffect(() => {
    if (!window.ethereum) {
      setError('请安装 MetaMask 钱包扩展');
      return;
    }
    const initProvider = new ethers.providers.Web3Provider(window.ethereum, 'any'); // 'any' 允许任何网络
    setProvider(initProvider);

    // 尝试获取已连接的账户
    initProvider.listAccounts().then((accounts) => {
      if (accounts.length > 0) {
        setAccount(accounts[0]);
        setSigner(initProvider.getSigner());
      }
    });

    // 获取当前网络链 ID
    initProvider.getNetwork().then((network) => {
      setChainId(network.chainId);
    });
  }, []);
}

注意new Web3Provider(window.ethereum, 'any') 中的 'any' 参数很重要,它告诉 ethers 我们接受任何网络,这样在监听网络切换时不会抛出错误。

第二步:实现连接与断开连接

连接函数需要处理用户交互和可能的拒绝。

// 在 useWallet 钩子内
const connectWallet = useCallback(async () => {
  if (!provider) {
    setError('Provider 未初始化');
    return;
  }
  setIsConnecting(true);
  setError('');
  try {
    // 这会触发 MetaMask 弹窗
    const accounts = await provider.send('eth_requestAccounts', []);
    const currentAccount = accounts[0];
    setAccount(currentAccount);
    setSigner(provider.getSigner());
    // 连接成功后,再获取一次最新的网络信息
    const network = await provider.getNetwork();
    setChainId(network.chainId);
  } catch (err: any) {
    console.error('连接钱包失败:', err);
    // 用户拒绝连接是最常见的错误
    setError(err.code === 4001 ? '用户拒绝了连接请求' : `连接失败: ${err.message}`);
  } finally {
    setIsConnecting(false);
  }
}, [provider]);

const disconnectWallet = useCallback(() => {
  // 注意:MetaMask 没有真正的“断开连接”API,这里只是清除本地状态
  setAccount('');
  setSigner(null);
  setChainId(0);
  // 在实际项目中,你可能还需要清除相关的应用状态(如用户余额、NFT等)
}, []);

这里有个坑disconnectWallet 并不能让 MetaMask 忘记你的网站授权。真正的“断开”需要用户在 MetaMask 界面手动操作。我们只是在前端清除了状态。

第三步:监听账户与网络变化

这是实现“状态同步”的核心。我们需要监听 window.ethereum 发出的事件。

// 在 useWallet 钩子的 useEffect 中,初始化之后
useEffect(() => {
  if (!window.ethereum) return;

  // 监听账户变更
  const handleAccountsChanged = (accounts: string[]) => {
    console.log('accountsChanged', accounts);
    if (accounts.length === 0) {
      // MetaMask 被锁定或用户主动断开连接了所有账户
      disconnectWallet();
    } else if (accounts[0] !== account) {
      // 用户切换了账户
      setAccount(accounts[0]);
      if (provider) {
        setSigner(provider.getSigner());
      }
    }
  };

  // 监听链 ID 变更(网络切换)
  const handleChainChanged = (_chainId: string) => {
    // 注意:MetaMask 文档建议在链变更时刷新页面,但现代 DApp 通常不这样做
    // 我们只是更新 chainId 状态,组件可以根据新 chainId 做出反应(如提示切换网络)
    console.log('chainChanged', _chainId);
    // chainId 是十六进制字符串,需要转换
    setChainId(parseInt(_chainId, 16));
    // 网络变了,provider 和 signer 实例其实还能用,但某些场景可能需要重置
    if (provider) {
      provider.getNetwork().then(network => setChainId(network.chainId));
    }
  };

  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  // 组件卸载时清除监听
  return () => {
    window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum?.removeListener('chainChanged', handleChainChanged);
  };
}, [provider, account, disconnectWallet]); // 依赖项要小心,避免重复绑定

关键细节chainChanged 事件回调的参数是十六进制字符串,而 etherschainId 是数字,需要转换。另外,监听器一定要在组件卸载时移除,防止内存泄漏。

第四步:在组件中使用并处理网络不匹配

最后,在组件中集成这个 Hook,并处理一个常见业务逻辑:如果用户不在我们支持的网络上,提示他切换。

// components/WalletConnector.tsx
import React from 'react';
import { useWallet } from '../hooks/useWallet';
import { shortenAddress } from '../utils/address'; // 一个格式化地址的辅助函数

const SUPPORTED_CHAIN_ID = 1; // 假设我们只支持以太坊主网

export const WalletConnector: React.FC = () => {
  const {
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
  } = useWallet();

  const isOnSupportedNetwork = chainId === SUPPORTED_CHAIN_ID;

  const handleSwitchNetwork = async () => {
    if (!window.ethereum) return;
    try {
      // 尝试切换到以太坊主网
      await window.ethereum.request({
        method: 'wallet_switchEthereumChain',
        params: [{ chainId: '0x1' }], // 主网的十六进制链ID
      });
    } catch (switchError: any) {
      // 如果用户没有添加该网络,可以尝试添加它
      if (switchError.code === 4902) {
        try {
          await window.ethereum.request({
            method: 'wallet_addEthereumChain',
            params: [{
              chainId: '0x1',
              chainName: 'Ethereum Mainnet',
              nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
              rpcUrls: ['https://mainnet.infura.io/v3/YOUR_INFURA_KEY'],
              blockExplorerUrls: ['https://etherscan.io'],
            }],
          });
        } catch (addError) {
          console.error('添加网络失败', addError);
        }
      }
      console.error('切换网络失败', switchError);
    }
  };

  if (error && !window.ethereum) {
    return <div className="error">未检测到钱包,请安装 MetaMask。</div>;
  }

  return (
    <div className="wallet-connector">
      {!account ? (
        <button onClick={connectWallet} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接钱包'}
        </button>
      ) : (
        <div className="wallet-info">
          {!isOnSupportedNetwork && (
            <div className="network-warning">
              当前网络不受支持。
              <button onClick={handleSwitchNetwork}>切换到主网</button>
            </div>
          )}
          <span className="address">{shortenAddress(account)}</span>
          <button onClick={disconnectWallet} className="disconnect-btn">
            断开
          </button>
        </div>
      )}
      {error && <div className="error">{error}</div>}
    </div>
  );
};

完整代码

考虑到篇幅,这里提供一个整合后的 hooks/useWallet.ts 核心代码概览,以及一个简单的 utils/address.ts

// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

declare global {
  interface Window {
    ethereum?: any;
  }
}

export const useWallet = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  const disconnectWallet = useCallback(() => {
    setAccount('');
    setSigner(null);
    setChainId(0);
  }, []);

  const connectWallet = useCallback(async () => {
    if (!provider) {
      setError('Provider 未初始化');
      return;
    }
    setIsConnecting(true);
    setError('');
    try {
      const accounts = await provider.send('eth_requestAccounts', []);
      const currentAccount = accounts[0];
      setAccount(currentAccount);
      setSigner(provider.getSigner());
      const network = await provider.getNetwork();
      setChainId(network.chainId);
    } catch (err: any) {
      console.error('连接钱包失败:', err);
      setError(err.code === 4001 ? '用户拒绝了连接请求' : `连接失败: ${err.message}`);
    } finally {
      setIsConnecting(false);
    }
  }, [provider]);

  useEffect(() => {
    if (!window.ethereum) {
      setError('请安装 MetaMask 钱包扩展');
      return;
    }
    const initProvider = new ethers.providers.Web3Provider(window.ethereum, 'any');
    setProvider(initProvider);

    initProvider.listAccounts().then((accounts) => {
      if (accounts.length > 0) {
        setAccount(accounts[0]);
        setSigner(initProvider.getSigner());
      }
    });

    initProvider.getNetwork().then((network) => {
      setChainId(network.chainId);
    });

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnectWallet();
      } else if (accounts[0] !== account) {
        setAccount(accounts[0]);
        if (initProvider) {
          setSigner(initProvider.getSigner());
        }
      }
    };

    const handleChainChanged = (_chainId: string) => {
      setChainId(parseInt(_chainId, 16));
    };

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum?.removeListener('chainChanged', handleChainChanged);
    };
  }, [disconnectWallet]); // 注意依赖,这里只依赖了稳定的 disconnectWallet

  return {
    provider,
    signer,
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
  };
};

// utils/address.ts
export const shortenAddress = (address: string, chars = 4): string => {
  if (!address) return '';
  return `${address.substring(0, chars + 2)}...${address.substring(42 - chars)}`;
};

踩坑记录

  1. Provider 未初始化错误:在 connectWallet 函数中直接使用 provider,但 provider 的初始化在 useEffect 中,是异步的。在用户快速点击连接按钮时,provider 可能还是 null解决:在函数开始处增加 if (!provider) return; 的判断。
  2. 重复监听事件导致内存泄漏:最初我把事件监听写在了一个没有依赖数组的 useEffect 里,导致组件每次渲染都绑定新监听器,旧监听器未移除。解决:确保 useEffect 有正确的依赖数组,并在清理函数中 removeListener
  3. 网络切换后 signer 失效的错觉:用户切换网络后,我最初错误地认为需要重新创建 providersigner。实际上,ethersWeb3Provider 实例在传入 'any' 参数后,可以跨网络工作,signer 仍然有效。需要更新的只是 chainId 状态。解决:在 handleChainChanged 中只更新 chainId,除非有特定业务需求,否则不重置 provider/signer
  4. chainId 类型不一致ethersgetNetwork() 返回的 chainIdnumber,而 window.ethereumchainChanged 事件返回的是十六进制 string。直接比较会导致判断失败。解决:统一转换为数字类型再比较,使用 parseInt(_chainId, 16)

小结

通过这次实践,我深刻体会到 Web3 前端连接钱包不仅仅是弹出一个授权窗口,更是一个需要持续监听和同步外部状态(账户、网络)的复杂功能。封装一个自定义 Hook 来集中管理这些状态和副作用,是让代码保持清晰、可维护的关键。下一步,可以在此基础上集成钱包余额查询、交易发送监听、以及多钱包提供商(如 WalletConnect)的支持。

Solana前端开发:从连接钱包到发送交易,我如何用@solana/web3.js搞定第一个DApp

背景

上个月,团队接了一个Solana生态的NFT项目,需要开发一个允许用户连接钱包、查看余额并铸造NFT的前端界面。作为一个在以太坊和EVM兼容链上摸爬滚打了五年的前端,我的工具箱里装满了ethers.jsviemwagmi。当任务切换到Solana时,我意识到得从头学起。核心的挑战很明确:我需要快速掌握@solana/web3.js这个官方SDK,用它来实现钱包连接、读取链上数据和发送交易这些基础但至关重要的功能。一开始我以为这和以太坊开发大同小异,结果一脚踩进了好几个坑里。

问题分析

我的第一反应是去翻@solana/web3.js的官方文档和示例。文档结构清晰,但当我试图把文档里的代码片段拼凑成一个完整的React应用时,问题来了。首先,钱包连接逻辑和以太坊的window.ethereum完全不同,Solana主流钱包如Phantom将接口注入到window.solana。其次,账户模型差异巨大:Solana使用公钥(PublicKey)作为地址,交易需要“最近区块哈希”和“手续费支付者”等概念,这让我一开始构建交易时屡屡失败。最初的几次尝试,不是钱包弹不出连接框,就是交易签名后发送失败,控制台报错信息又比较晦涩。我意识到,不能只是机械地复制代码,必须理解Solana交易构建的基本流程。

核心实现

1. 环境搭建与钱包连接

首先,我创建了一个新的React + TypeScript项目,并安装核心依赖:

npm install @solana/web3.js @solana/wallet-adapter-base @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets

这里有个关键点:单纯用@solana/web3.js也能连接钱包,但社区更推荐使用@solana/wallet-adapter-*这一套工具库,它封装了连接逻辑和UI组件,能省不少事。

接下来,我设置钱包上下文。这是整个应用能调用钱包功能的基础:

// App.tsx
import React, { useMemo } from 'react';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-wallets';
import { clusterApiUrl } from '@solana/web3.js';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { MyComponent } from './MyComponent';

// 导入默认样式
import '@solana/wallet-adapter-react-ui/styles.css';

function App() {
  // 配置网络。开发时通常用devnet或testnet,这里用devnet
  const network = WalletAdapterNetwork.Devnet;
  const endpoint = useMemo(() => clusterApiUrl(network), [network]);

  // 配置支持的钱包列表
  const wallets = useMemo(
    () => [
      new PhantomWalletAdapter(),
      // 可以继续添加其他钱包适配器,如Solflare
    ],
    []
  );

  return (
    <ConnectionProvider endpoint={endpoint}>
      <WalletProvider wallets={wallets} autoConnect>
        <WalletModalProvider>
          <MyComponent />
        </WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  );
}

export default App;

注意这个细节ConnectionProviderendpoint参数是必须的,它指定了你的应用要连接哪个Solana集群(主网、测试网等)。autoConnect属性会在页面加载时尝试重新连接上次的钱包,提升用户体验。

2. 获取钱包地址与余额

在子组件MyComponent中,我使用适配器提供的钩子来获取钱包状态和连接信息。

// MyComponent.tsx
import React, { useState, useEffect } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { LAMPORTS_PER_SOL } from '@solana/web3.js';

export const MyComponent: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, connected } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);

  // 当钱包连接状态或公钥变化时,获取余额
  useEffect(() => {
    const fetchBalance = async () => {
      if (connected && publicKey) {
        setLoading(true);
        try {
          // 注意:getBalance返回的是lamports,1 SOL = 10^9 lamports
          const lamportsBalance = await connection.getBalance(publicKey);
          setBalance(lamportsBalance / LAMPORTS_PER_SOL); // 转换为SOL单位
        } catch (error) {
          console.error('获取余额失败:', error);
          setBalance(null);
        } finally {
          setLoading(false);
        }
      } else {
        setBalance(null);
      }
    };

    fetchBalance();
  }, [connection, publicKey, connected]);

  return (
    <div>
      <p>钱包状态: {connected ? '已连接' : '未连接'}</p>
      {publicKey && <p>钱包地址: {publicKey.toBase58()}</p>}
      {loading && <p>查询余额中...</p>}
      {balance !== null && !loading && <p>余额: {balance} SOL</p>}
    </div>
  );
};

这里有个坑connection.getBalance()返回的单位是lamports,而不是SOL。直接显示这个数字会非常大,必须除以LAMPORTS_PER_SOL(10^9)来转换。我一开始没注意,显示了一个9位数的“余额”,闹了笑话。

3. 构建并发送一笔SOL转账交易

这是最核心也最容易出错的部分。在Solana上,一笔交易可以包含多个指令,我们需要构建一个“系统程序”的转账指令。

// 在MyComponent.tsx中添加发送交易函数
import { SystemProgram, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';

const sendTransaction = async () => {
  // 1. 基础校验
  if (!publicKey || !connected) {
    alert('请先连接钱包');
    return;
  }
  if (!connection) {
    alert('连接异常');
    return;
  }

  // 2. 构建交易指令
  // 假设我们向这个地址转账0.01 SOL
  const toPublicKey = new PublicKey('接收方的Solana地址(Base58格式)');
  const transferAmount = 0.01; // SOL
  const lamportsToSend = transferAmount * LAMPORTS_PER_SOL;

  const transferInstruction = SystemProgram.transfer({
    fromPubkey: publicKey,
    toPubkey: toPublicKey,
    lamports: lamportsToSend,
  });

  // 3. 创建交易并添加指令
  const transaction = new Transaction().add(transferInstruction);

  // 4. 获取“最近区块哈希”(Recent Blockhash)——这是Solana交易必需的
  let blockhash;
  try {
    const { blockhash: recentBlockhash } = await connection.getLatestBlockhash();
    blockhash = recentBlockhash;
    transaction.recentBlockhash = blockhash;
    // 5. 设置交易的费用支付者(Fee Payer)
    transaction.feePayer = publicKey;
  } catch (error) {
    console.error('获取区块哈希失败:', error);
    alert('获取网络信息失败,请重试');
    return;
  }

  // 6. 请求钱包签名并发送
  try {
    // 这里使用了wallet-adapter的signTransaction方法
    // 注意:在真实场景中,我们通常使用wallet-adapter提供的sendTransaction方法,它内部处理了签名和发送。
    // 但为了演示底层过程,这里先展示需要手动签名的流程,后面会给出更优方案。
    const signedTransaction = await signTransaction(transaction); // 假设signTransaction来自useWallet
    const signature = await connection.sendRawTransaction(signedTransaction.serialize());
    console.log('交易已发送,签名:', signature);

    // 7. 确认交易
    const confirmation = await connection.confirmTransaction(signature);
    if (confirmation.value.err) {
      throw new Error('交易确认失败');
    }
    alert(`转账成功!交易签名: ${signature}`);
  } catch (error: any) {
    console.error('发送交易失败:', error);
    alert(`交易失败: ${error.message}`);
  }
};

注意这个细节recentBlockhashfeePayer是Solana交易对象必须设置的两个属性,缺一不可。忘记设置feePayer是我遇到的第一个报错。recentBlockhash用于防止交易重放,并让验证者知道交易的有效期。

4. 使用Wallet Adapter优化交易发送

上面的手动签名流程比较繁琐,而且useWallet钩子并不直接暴露signTransaction方法。实际上,@solana/wallet-adapter-react提供了更优雅的sendTransaction方法。

// 这是更推荐的实践,修改MyComponent.tsx
import { useConnection, useWallet } from '@solana/wallet-adapter-react';

const { connection } = useConnection();
const { publicKey, sendTransaction } = useWallet(); // 使用钩子提供的sendTransaction

const sendTransactionEasy = async () => {
  if (!publicKey) return;

  const toPublicKey = new PublicKey('接收方地址');
  const lamportsToSend = 0.01 * LAMPORTS_PER_SOL;

  const transaction = new Transaction().add(
    SystemProgram.transfer({
      fromPubkey: publicKey,
      toPubkey: toPublicKey,
      lamports: lamportsToSend,
    })
  );

  // 关键步骤:获取区块哈希并设置
  const { blockhash } = await connection.getLatestBlockhash();
  transaction.recentBlockhash = blockhash;
  transaction.feePayer = publicKey;

  try {
    // 一行代码搞定:钱包适配器会处理弹窗签名、发送、获取签名结果
    const signature = await sendTransaction(transaction, connection);
    console.log('交易签名:', signature);

    // 可选:等待交易确认
    const result = await connection.confirmTransaction(signature, 'confirmed');
    console.log('确认结果:', result);
    alert('转账成功!');
  } catch (error: any) {
    console.error('交易出错:', error);
    alert(`用户拒绝或交易失败: ${error.message}`);
  }
};

这里有个巨大的进步:使用钱包适配器提供的sendTransaction方法,我们不需要手动处理签名、序列化、发送原始交易这些底层细节。它会自动触发钱包的签名请求,并返回交易签名。代码简洁且健壮。

完整代码

以下是一个整合了所有功能、可以直接运行的MyComponent.tsx示例:

import React, { useState, useEffect } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { SystemProgram, Transaction, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';

export const MyComponent: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, connected, sendTransaction } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const [sending, setSending] = useState(false);
  const [recipient, setRecipient] = useState('');

  // 获取余额
  useEffect(() => {
    const fetchBalance = async () => {
      if (connected && publicKey) {
        setLoading(true);
        try {
          const lamportsBalance = await connection.getBalance(publicKey);
          setBalance(lamportsBalance / LAMPORTS_PER_SOL);
        } catch (error) {
          console.error('获取余额失败:', error);
          setBalance(null);
        } finally {
          setLoading(false);
        }
      } else {
        setBalance(null);
      }
    };
    fetchBalance();
  }, [connection, publicKey, connected]);

  // 发送SOL交易
  const handleSendSol = async () => {
    if (!publicKey || !recipient) {
      alert('请先连接钱包并填写接收地址');
      return;
    }
    let toPubkey;
    try {
      toPubkey = new PublicKey(recipient);
    } catch {
      alert('接收地址格式无效');
      return;
    }

    const transferAmount = 0.01; // 固定转账0.01 SOL,实际项目可以做成输入框
    const lamportsToSend = transferAmount * LAMPORTS_PER_SOL;

    const transaction = new Transaction().add(
      SystemProgram.transfer({
        fromPubkey: publicKey,
        toPubkey: toPubkey,
        lamports: lamportsToSend,
      })
    );

    try {
      const { blockhash } = await connection.getLatestBlockhash();
      transaction.recentBlockhash = blockhash;
      transaction.feePayer = publicKey;

      setSending(true);
      const signature = await sendTransaction(transaction, connection);
      console.log('交易完成,签名:', signature);

      // 等待最终确认,提供更好反馈
      await connection.confirmTransaction(signature, 'confirmed');
      alert(`成功转账${transferAmount} SOL!交易签名: ${signature}`);
      setRecipient(''); // 清空输入框
      // 重新获取余额
      const newBalance = await connection.getBalance(publicKey);
      setBalance(newBalance / LAMPORTS_PER_SOL);
    } catch (error: any) {
      console.error('交易失败:', error);
      if (error.message.includes('User rejected')) {
        alert('您拒绝了交易签名。');
      } else {
        alert(`交易失败: ${error.message}`);
      }
    } finally {
      setSending(false);
    }
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h1>Solana Web3.js 入门实战</h1>
      <div style={{ marginBottom: '20px' }}>
        <WalletMultiButton />
      </div>

      {connected && publicKey && (
        <div>
          <p>
            <strong>钱包地址:</strong> {publicKey.toBase58()}
          </p>
          <p>
            <strong>余额:</strong>{' '}
            {loading ? '加载中...' : balance !== null ? `${balance.toFixed(4)} SOL` : '--'}
          </p>

          <hr style={{ margin: '20px 0' }} />

          <h3>发送 SOL 测试</h3>
          <div>
            <input
              type="text"
              placeholder="输入接收方Solana地址"
              value={recipient}
              onChange={(e) => setRecipient(e.target.value)}
              style={{ width: '400px', padding: '8px', marginRight: '10px' }}
            />
            <button onClick={handleSendSol} disabled={sending || !recipient}>
              {sending ? '发送中...' : '发送 0.01 SOL'}
            </button>
            <p style={{ fontSize: '0.9em', color: '#666', marginTop: '5px' }}>
              请确保在Devnet网络,并使用Devnet的SOL进行测试。
            </p>
          </div>
        </div>
      )}
      {!connected && <p>请点击上方按钮连接钱包(推荐Phantom)。</p>}
    </div>
  );
};

踩坑记录

  1. “Cannot read properties of undefined (reading ‘solana’)”:这是我遇到的第一个错误。原因是我在没有安装Phantom钱包(或任何Solana钱包)的浏览器中运行代码。window.solana对象不存在。解决方法:在代码中增加判断,或者引导用户安装钱包。钱包适配器的UI按钮会自动处理这个状态。

  2. “Transaction recentBlockhash required”:构建交易后发送失败。我忘记给交易对象transaction设置recentBlockhash属性。解决方法:在发送交易前,必须调用connection.getLatestBlockhash()并赋值给transaction.recentBlockhash

  3. “FeePayer must be a PublicKey”:设置了recentBlockhash后依然报错。因为我连feePayer也没设置。解决方法:将当前用户的公钥publicKey赋值给transaction.feePayer。记住,这两个属性是Solana Transaction对象的必选项。

  4. 交易签名成功但链上确认失败:在测试网发送交易,钱包签名弹窗成功了,但最后交易失败。原因是我用的RPC节点不稳定或响应慢。解决方法:更换更稳定、快速的RPC端点。对于开发,可以使用Solana基金会提供的公共端点clusterApiUrl(‘devnet’),但对于生产环境,需要考虑使用付费的私有RPC服务以获得更好的可靠性。

小结

通过这个从零到一的实践,我深刻体会到Solana前端开发在交易构建细节上与EVM的差异。核心收获是:理解Solana交易必须包含recentBlockhashfeePayer,并善用@solana/wallet-adapter系列工具库能极大提升开发效率。下一步,我可以基于此继续探索如何与SPL代币(类似ERC20)交互、如何解析NFT元数据,以及如何与自定义的智能合约(Solana上称为程序)进行交互。

❌