从报错到跑通:我用 @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,比如 ConnectionPublicKeyTransaction。但当我真正开始写代码时,各种问题接踵而至:钱包连接后拿不到公钥、交易发送后报错"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 对象在连接钱包之前,publicKeynull。这和 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 的交易构建比以太坊复杂一点,因为需要指定 recentBlockhashfeePayer。我一开始忘了设置 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

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

相关推荐
Asize1 小时前
重生之我在 Vibe Coding 时代当程序员:第十六课,从模拟队列到原型链
前端·javascript·后端
vim怎么退出1 小时前
Dive into React——高级特性
前端·react.js·源码阅读
冰暮流星1 小时前
javascript之this关键字
开发语言·前端·javascript
余大大.1 小时前
SystemVerilog-参数宏与拼接符的使用
前端
羸弱的穷酸书生1 小时前
跟AI学一手之前端导出
前端·文件导出
怕浪猫1 小时前
Electron 开发实战(十三):性能优化策略|极速启动、低内存、流畅渲染、极致瘦身
前端·javascript·electron
Csvn1 小时前
React useEffect 异步竞态:90% 的人都踩过的坑
前端·react.js
如果超人不会飞1 小时前
用TinyRobot Bubble组件打造灵活强大的AI对话气泡
前端·vue.js