从报错到跑通:我用 @solana/web3.js 在 React 中实现 Solana 钱包连接的全过程

1. 背景:一个 NFT 铸造页面的前端需求

上个月,我参与了一个 Solana 链上的 NFT 铸造项目。项目方要求前端实现一个简单的页面:用户点击"Connect Wallet"按钮连接 Phantom 钱包,然后点击"Mint"按钮铸造 NFT。我当时的想法是:"不就是连接钱包吗?用 ethers.js 写过很多次了,Solana 应该差不多。"结果我错了。

项目用的是 React + TypeScript,我翻了一下 @solana/web3.js 的文档,发现它确实提供了一些类似 ethers.js 的 API,比如 Connection、PublicKey、Transaction。但当我真正开始写代码时,各种问题接踵而至:钱包连接后拿不到公钥、交易发送后报错"simulation failed"、甚至 Phantom 弹窗都不出来。这篇文章就是我把这些坑一个个填平的过程。

2. 问题分析:为什么我的 Solana 前端一上来就崩了?

最初的思路:照着 ethers.js 的写法

我习惯用 ethers.js 做以太坊开发,思路一般是:

  1. 通过 window.ethereum 获取 provider
  2. 调用 eth_requestAccounts 获取账户
  3. 用 signer 发送交易

于是我想当然地写了一段类似的 Solana 代码:

typescript 复制代码
// 错误写法:直接照搬 ethers.js 的思路
const provider = window.solana; // 获取 Phantom 对象
const account = provider.publicKey.toString(); // 以为能直接拿到地址
const transaction = new Transaction().add(/* ... */);
await provider.sendTransaction(transaction); // 直接发送

结果控制台直接报错:Cannot read properties of undefined (reading 'toString')。我 debug 了半天才发现,Phantom 的 window.solana 对象在连接钱包之前,publicKey 是 null。这和 MetaMask 不一样------MetaMask 会在页面加载时自动注入账户信息,但 Phantom 必须用户手动点击连接后才会暴露公钥。

排查过程:翻文档 + 看 Phantom 官方示例

我花了一下午翻 @solana/web3.js 的文档和 Phantom 官方的 React 集成示例。发现 Solana 前端开发有几个关键点:

  1. 钱包连接是异步的 :必须先调用 provider.connect(),然后才能访问 publicKey。
  2. 交易需要签名 :sendTransaction 不会自动弹出签名窗口,需要先构建 Transaction 对象,再调用 provider.signAndSendTransaction。
  3. 网络配置很重要 :如果不指定 Connection 的 RPC 节点,默认走 mainnet-beta,但测试时应该用 devnet。

意识到这些问题后,我决定从头开始,一步步实现一个完整可用的钱包连接和交易发送流程。

3. 核心实现:从零搭建 Solana 钱包连接

3.1 初始化 Connection 和网络选择

Solana 的 Connection 类类似于 ethers.js 的 JsonRpcProvider,用于与区块链交互。但有个坑:如果你不指定 commitment 参数,默认是 'finalized',这意味着交易确认时间会很长。我当时测试时等了 30 秒都没反应,后来发现改成 'confirmed' 就快多了。

typescript 复制代码
// 初始化 Solana 连接
import { Connection, clusterApiUrl } from '@solana/web3.js';

// 选择网络:开发阶段用 devnet,上线前切换为 mainnet-beta
const network = 'devnet'; // 或者 'mainnet-beta'
const connection = new Connection(clusterApiUrl(network), {
  commitment: 'confirmed', // 这里用 'confirmed' 而不是 'finalized',能显著减少等待时间
});

注意 :clusterApiUrl 返回的是 Solana 官方提供的公共 RPC 节点,生产环境建议换成自己的 RPC(比如 Helius 或 QuickNode),否则频繁请求容易被限流。

3.2 连接 Phantom 钱包并获取公钥

这是最核心的一步。我一开始犯的错误是直接读取 window.solana.publicKey,但没先调用 connect()。正确的做法是:

typescript 复制代码
// 连接 Phantom 钱包
async function connectWallet(): Promise<string> {
  const { solana } = window as any; // 注意:TypeScript 需要声明 window 上的 solana 属性

  if (!solana || !solana.isPhantom) {
    throw new Error('Phantom 钱包未安装');
  }

  // 关键:必须先调用 connect(),然后才能获取 publicKey
  const response = await solana.connect();
  const publicKey = response.publicKey.toString();

  console.log('钱包连接成功,公钥:', publicKey);
  return publicKey;
}

这里有个坑 :solana.connect() 返回的 response 对象包含 publicKey 属性,但有些旧版本的 Phantom 会直接返回一个 PublicKey 对象而不是 { publicKey }。所以我加了一个兼容性处理:

typescript 复制代码
// 兼容不同版本的 Phantom
const publicKey = response.publicKey?.toString() || response.toString();

3.3 构建并发送交易

拿到公钥后,下一步是构建交易。Solana 的交易构建比以太坊复杂一点,因为需要指定 recentBlockhash 和 feePayer。我一开始忘了设置 recentBlockhash,结果报错"Transaction missing recent blockhash"。

typescript 复制代码
import { Transaction, SystemProgram, LAMPORTS_PER_SOL } from '@solana/web3.js';

// 构建一个简单的转账交易(发送 0.1 SOL)
async function sendTransaction(fromPublicKey: string, toPublicKey: string) {
  const { solana } = window as any;
  const fromPubkey = new PublicKey(fromPublicKey);
  const toPubkey = new PublicKey(toPublicKey);

  // 第一步:获取最新的 blockhash
  const { blockhash } = await connection.getLatestBlockhash();
  
  // 第二步:构建交易
  const transaction = new Transaction();
  transaction.recentBlockhash = blockhash; // 必须设置,否则交易无效
  transaction.feePayer = fromPubkey; // 谁支付手续费

  // 添加转账指令
  transaction.add(
    SystemProgram.transfer({
      fromPubkey,
      toPubkey,
      lamports: 0.1 * LAMPORTS_PER_SOL, // 0.1 SOL = 100,000,000 lamports
    })
  );

  // 第三步:发送并签名
  const signature = await solana.signAndSendTransaction(transaction);
  console.log('交易已发送,签名:', signature);

  // 第四步:等待确认
  await connection.confirmTransaction(signature, 'confirmed');
  console.log('交易已确认');
}

注意 :signAndSendTransaction 是 Phantom 提供的方法,它会自动弹出签名窗口。如果用户拒绝签名,会抛出一个错误,需要捕获处理。

3.4 在 React 组件中集成

现在把上面的逻辑封装成 React Hooks,方便复用。我写了一个 useWallet 钩子:

typescript 复制代码
import { useState, useCallback } from 'react';

interface WalletState {
  connected: boolean;
  publicKey: string | null;
  balance: number | null;
  error: string | null;
}

export function useWallet() {
  const [state, setState] = useState<WalletState>({
    connected: false,
    publicKey: null,
    balance: null,
    error: null,
  });

  const connect = useCallback(async () => {
    try {
      const { solana } = window as any;
      if (!solana?.isPhantom) {
        throw new Error('请安装 Phantom 钱包');
      }

      const response = await solana.connect();
      const publicKey = response.publicKey.toString();
      
      // 连接成功后查询余额
      const balance = await connection.getBalance(new PublicKey(publicKey));
      
      setState({
        connected: true,
        publicKey,
        balance: balance / LAMPORTS_PER_SOL,
        error: null,
      });
    } catch (err: any) {
      setState(prev => ({ ...prev, error: err.message }));
    }
  }, []);

  const disconnect = useCallback(async () => {
    const { solana } = window as any;
    if (solana) {
      await solana.disconnect();
    }
    setState({
      connected: false,
      publicKey: null,
      balance: null,
      error: null,
    });
  }, []);

  return { ...state, connect, disconnect };
}

然后组件中这样用:

tsx 复制代码
function App() {
  const { connected, publicKey, balance, error, connect, disconnect } = useWallet();

  return (
    <div>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {!connected ? (
        <button onClick={connect}>Connect Phantom Wallet</button>
      ) : (
        <div>
          <p>地址: {publicKey}</p>
          <p>余额: {balance} SOL</p>
          <button onClick={disconnect}>Disconnect</button>
          <button onClick={handleMint}>Mint NFT</button>
        </div>
      )}
    </div>
  );
}

4. 完整代码:可直接运行的 React 组件

下面是一个完整的、可直接复制运行的示例,包含了钱包连接、余额查询和发送交易的功能。注意:需要先安装依赖:

bash 复制代码
npm install @solana/web3.js
typescript 复制代码
// App.tsx
import React, { useState, useCallback } from 'react';
import {
  Connection,
  PublicKey,
  Transaction,
  SystemProgram,
  LAMPORTS_PER_SOL,
  clusterApiUrl,
} from '@solana/web3.js';

// 初始化连接
const connection = new Connection(clusterApiUrl('devnet'), {
  commitment: 'confirmed',
});

// 目标地址(示例)
const TARGET_ADDRESS = 'Gjq3...'; // 替换为实际的接收地址

function App() {
  const [publicKey, setPublicKey] = useState<string | null>(null);
  const [balance, setBalance] = useState<number | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const connectWallet = useCallback(async () => {
    try {
      const { solana } = window as any;
      if (!solana?.isPhantom) {
        throw new Error('请安装 Phantom 钱包');
      }

      const response = await solana.connect();
      const pubkey = response.publicKey.toString();
      setPublicKey(pubkey);

      // 查询余额
      const bal = await connection.getBalance(new PublicKey(pubkey));
      setBalance(bal / LAMPORTS_PER_SOL);
      setError(null);
    } catch (err: any) {
      setError(err.message);
    }
  }, []);

  const disconnectWallet = useCallback(async () => {
    const { solana } = window as any;
    if (solana) {
      await solana.disconnect();
    }
    setPublicKey(null);
    setBalance(null);
  }, []);

  const sendTransaction = useCallback(async () => {
    if (!publicKey) return;
    setLoading(true);
    try {
      const { solana } = window as any;
      const fromPubkey = new PublicKey(publicKey);
      const toPubkey = new PublicKey(TARGET_ADDRESS);

      // 获取 blockhash
      const { blockhash } = await connection.getLatestBlockhash();

      // 构建交易
      const transaction = new Transaction();
      transaction.recentBlockhash = blockhash;
      transaction.feePayer = fromPubkey;
      transaction.add(
        SystemProgram.transfer({
          fromPubkey,
          toPubkey,
          lamports: 0.01 * LAMPORTS_PER_SOL, // 发送 0.01 SOL
        })
      );

      // 签名并发送
      const signature = await solana.signAndSendTransaction(transaction);
      console.log('交易签名:', signature);

      // 等待确认
      await connection.confirmTransaction(signature, 'confirmed');
      console.log('交易已确认');

      // 更新余额
      const newBalance = await connection.getBalance(fromPubkey);
      setBalance(newBalance / LAMPORTS_PER_SOL);
      setError(null);
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [publicKey]);

  return (
    <div style={{ padding: '20px' }}>
      <h1>Solana 钱包连接示例</h1>
      {error && <p style={{ color: 'red' }}>错误: {error}</p>}
      {!publicKey ? (
        <button onClick={connectWallet}>连接 Phantom 钱包</button>
      ) : (
        <div>
          <p>公钥: {publicKey}</p>
          <p>余额: {balance !== null ? `${balance.toFixed(4)} SOL` : '加载中...'}</p>
          <button onClick={disconnectWallet}>断开连接</button>
          <button onClick={sendTransaction} disabled={loading}>
            {loading ? '发送中...' : '发送 0.01 SOL'}
          </button>
        </div>
      )}
    </div>
  );
}

export default App;

5. 踩坑记录:我实际遇到的 4 个报错

坑 1:Cannot read properties of undefined (reading 'isPhantom')

原因:Phantom 钱包未安装或未启用。有些用户安装了钱包但没在浏览器中启用扩展。

解决:增加更详细的检测逻辑,并提示用户检查扩展状态:

typescript 复制代码
if (typeof window.solana === 'undefined') {
  throw new Error('未检测到 Solana 钱包,请安装 Phantom 或 Solflare');
}
if (!window.solana.isPhantom) {
  throw new Error('当前钱包不是 Phantom,请切换到 Phantom');
}

坑 2:Transaction simulation failed: Blockhash not found

原因 :recentBlockhash 过期了。Solana 的 blockhash 有有效期(大约 150 个 slot),如果构建交易后等待太久再发送就会失效。

解决 :在发送前重新获取 blockhash,或者使用 getLatestBlockhashAndContext 并检查 lastValidBlockHeight:

typescript 复制代码
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
// 发送交易前检查当前 block height 是否超过 lastValidBlockHeight
const currentBlockHeight = await connection.getBlockHeight();
if (currentBlockHeight > lastValidBlockHeight) {
  // 重新获取 blockhash
}

坑 3:Error processing Instruction 0: custom program error: 0x1

原因:这个错误通常出现在 NFT 铸造时,表示程序(Program)拒绝了交易。可能原因是账户余额不足、参数错误或权限问题。

解决 :启用 preflightCommitment: 'confirmed' 并查看详细错误日志:

typescript 复制代码
const signature = await solana.signAndSendTransaction(transaction, {
  preflightCommitment: 'confirmed', // 在模拟阶段就使用 confirmed 确认
});
// 如果还是报错,用 solana 的 RPC 日志查看具体原因

坑 4:User rejected the request 后界面无反馈

原因 :用户拒绝签名时,signAndSendTransaction 会抛出一个错误,但如果不捕获,界面会卡在"加载中"状态。

解决:在错误处理中区分用户取消和其他错误:

typescript 复制代码
try {
  await solana.signAndSendTransaction(transaction);
} catch (err: any) {
  if (err.message.includes('User rejected')) {
    console.log('用户取消了签名');
    // 不显示红色错误,只重置状态
  } else {
    setError(err.message);
  }
}

6. 小结

这次经历让我深刻认识到:虽然 Solana 和以太坊都是区块链,但前端开发范式差异很大。核心收获是:Solana 的钱包连接必须显式调用 connect(),交易构建必须手动设置 recentBlockhash,并且要习惯用 signAndSendTransaction 而不是 sendTransaction。

如果你也想深入 Solana 前端开发,建议继续研究以下几个方向:

  • 使用 @solana/wallet-adapter-react 库简化多钱包支持
  • 学习 Solana 的 Program Derived Address (PDA) 和跨程序调用 (CPI)
  • 掌握 @solana/spl-token 处理代币和 NFT

希望这篇文章能帮你少走我走过的弯路。

相关推荐
Eiceblue36 分钟前
使用 Python 实现 Excel 行列转置(保留单元格样式)
前端·python·excel
西西学代码1 小时前
像素和颜色之间的转换(Flutter)
前端·javascript·flutter
茶底世界之下1 小时前
羽毛颜色已经很鲜艳,还要继续加色吗?用 Awaking 试一次减饱和
前端·ios·运营
风早爽太1 小时前
Mac 搭建 Vue 开发环境
前端·vue.js
Lank_M1 小时前
Word图片裁剪转HTML失效,转换器各漏读了哪一层
前端
青烟小生1 小时前
前端架构设计实战:从组件思维到系统思维
前端
Amos_Web2 小时前
Rspack 源码解析(二十):Typed Hook 系统如何靠宏生成
前端·rust·前端框架
程序员大阳2 小时前
副队长CSS教程(29)–CSS Grid 实战:实现通用图文混排布局
前端·css·grid·图文混排
对空六课2 小时前
无头浏览器访问对跳出率的过滤规则
服务器·前端·算法·数据分析
小番茄夫斯基2 小时前
Nginx 介绍
前端