从“连接不上”到“交易成功”:我用 @solana/web3.js 在 React 中搞定 Solana 钱包交互的全过程

背景:一个看似简单的 NFT 铸造需求

两个月前,我接手了一个 Solana 上的 NFT 铸造项目。产品需求很明确:用户在浏览器中连接 Phantom 钱包,输入数量,点击"铸造"按钮,就能 Mint 一个 NFT。听起来和以太坊上的流程差不多,我当时想:"不就是调个钱包 API 嘛,半天搞定。"

结果,我花了整整两天时间,才让第一笔交易成功上链。问题出在哪里?不是我不懂区块链,而是我对 Solana 的"前端开发范式"完全没概念。在以太坊上,我用 ethers.js 或 wagmi 习惯了,但 Solana 的账户模型、交易结构、钱包交互方式都和以太坊完全不同。更坑的是,@solana/web3.js 的版本迭代很快,网上很多教程用的是 v1.x,而现在已经是 v2.x 了,API 变化很大。

我当时的困境是:钱包连接上了,也能获取到地址,但一发送交易就报错,不是"invalid account"就是"failed to serialize"。这篇文章,就是我把整个排查和实现过程完整记录下来,希望帮到同样在 Solana 前端路上踩坑的你。

问题分析:为什么官方示例跑不通?

我的第一步,当然是去翻 @solana/web3.js 的官方文档。文档给了一个简单的"发送 SOL"示例,大概长这样:

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

const connection = new Connection('https://api.mainnet-beta.solana.com');
const transaction = new Transaction().add(
  SystemProgram.transfer({
    fromPubkey: sender.publicKey,
    toPubkey: receiver.publicKey,
    lamports: LAMPORTS_PER_SOL,
  })
);
const signature = await sendAndConfirmTransaction(connection, transaction, [sender]);

我当时心想:"这不挺简单吗?直接抄过来改改就行。" 但是,当我把这段代码放到 React 项目里时,问题接踵而至。

第一个坑:sendAndConfirmTransaction 这个函数在 v2.x 里已经废弃了。 是的,我用的 @solana/web3.js 版本是 2.0.0,这个 API 被移除了。取而代之的是 sendTransaction 和独立的 confirmTransaction 方法。但文档的示例还是旧的,我一开始没注意版本号,直接复制粘贴,然后报错说找不到这个函数。

第二个坑:钱包的 signTransaction 方法返回的是 Uint8Array,不是 Transaction 对象。 我用 Phantom 钱包的 window.solana.signTransaction(transaction) 时,发现它返回的是一个序列化后的字节数组,而 sendTransaction 需要的是 Transaction 对象。这就尴尬了------我到底该用哪个?

第三个坑:确认交易时,状态码的含义。 交易发送后,我需要等待确认。但 confirmTransaction 返回的 SignatureResult 里有个 err 字段,如果交易失败,err 是一个对象,不是简单的字符串。我第一次没做错误判断,直接用了 result.value 的布尔值,结果交易失败了还显示"成功"。

这三个坑让我意识到,官方文档只是告诉你"能做什么",但没告诉你"在真实项目中怎么做"。我需要一个完整的、能直接跑通的流程。

核心实现:从零搭建 Solana 前端交互

第一步:选对版本,装对依赖

我先确认了项目环境:React 18 + TypeScript + @solana/web3.js v2.0.0。安装命令很简单:

bash 复制代码
npm install @solana/web3.js@2.0.0

但这里有个细节:如果你需要钱包适配(比如 Phantom),还要装 @solana/wallet-adapter-wallets@solana/wallet-adapter-react。我当时只装了核心库,结果用 window.solana 直接操作时,发现它和 React 的状态管理不太兼容。后来我改用 @solana/wallet-adapter-base 来统一处理钱包连接,省心很多。

不过,为了更贴近"纯 @solana/web3.js"的使用场景,我决定在本文中只依赖核心库和 Phantom 的官方 API,不引入额外的钱包适配器。这样你就能更清楚地看到每一步在做什么。

注意: Solana 的 RPC 节点需要选择。我用的是 Helius 的公共节点(https://api.devnet.solana.com),因为开发网免费且稳定。主网的话,建议用 QuickNode 或自己搭建的节点,避免被限流。

第二步:连接钱包并获取账户信息

核心思路:使用 window.solana 对象(Phantom 注入的)来请求连接,然后获取用户的公钥。这里有个坑:window.solana 在页面加载时可能还没准备好,需要检查是否存在。

我写了一个自定义 Hook,专门处理钱包连接:

typescript 复制代码
// hooks/useWallet.ts
import { useEffect, useState } from 'react';
import { PublicKey, Connection } from '@solana/web3.js';

interface WalletState {
  publicKey: PublicKey | null;
  connected: boolean;
  connect: () => Promise<void>;
  disconnect: () => void;
}

export function useWallet(): WalletState {
  const [publicKey, setPublicKey] = useState<PublicKey | null>(null);
  const [connected, setConnected] = useState(false);

  const connect = async () => {
    try {
      // 检查 Phantom 是否安装
      if (!window.solana || !window.solana.isPhantom) {
        alert('请安装 Phantom 钱包!');
        return;
      }
      // 请求连接
      const response = await window.solana.connect();
      // response.publicKey 是一个 PublicKey 对象
      setPublicKey(response.publicKey);
      setConnected(true);
    } catch (error) {
      console.error('连接失败:', error);
    }
  };

  const disconnect = () => {
    window.solana.disconnect();
    setPublicKey(null);
    setConnected(false);
  };

  // 监听账户变化
  useEffect(() => {
    if (window.solana?.on) {
      window.solana.on('accountChanged', (publicKey: PublicKey | null) => {
        if (publicKey) {
          setPublicKey(publicKey);
        } else {
          setPublicKey(null);
          setConnected(false);
        }
      });
    }
    return () => {
      window.solana?.removeAllListeners('accountChanged');
    };
  }, []);

  return { publicKey, connected, connect, disconnect };
}

这里有个坑: 我一开始以为 window.solana.connect() 返回的是 { publicKey: string },但实际上它返回的是 { publicKey: PublicKey },而且 PublicKey 是一个类,不是普通的字符串。如果你直接把它存成字符串,后面做交易时会报类型错误。

第三步:构建并发送交易

这是最核心的部分。我需要在用户点击"铸造"按钮时,构建一个包含 NFT 铸造指令的交易,然后让用户用钱包签名,最后发送到链上。

Solana 的交易结构是:一个 Transaction 对象包含一个或多个 Instruction,每个 Instruction 指定程序 ID、账户列表和数据。对于 NFT 铸造,我需要调用 Metaplex 的 Candy Machine 程序,但为了简化演示,这里我用一个简单的 SOL 转账为例,流程是一样的。

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

export async function sendSol(
  connection: Connection,
  fromPubkey: PublicKey,
  toPubkey: PublicKey,
  amount: number
): Promise<string> {
  // 构建交易
  const transaction = new Transaction().add(
    SystemProgram.transfer({
      fromPubkey: fromPubkey,
      toPubkey: toPubkey,
      lamports: amount * LAMPORTS_PER_SOL, // 注意单位转换
    })
  );

  // 设置交易参数:最新区块哈希和费用
  const { blockhash } = await connection.getLatestBlockhash();
  transaction.recentBlockhash = blockhash;
  transaction.feePayer = fromPubkey;

  // 用钱包签名
  const signedTransaction = await window.solana.signTransaction(transaction);

  // 发送交易
  const signature = await connection.sendRawTransaction(signedTransaction.serialize());

  // 等待确认
  const confirmation = await connection.confirmTransaction(signature, 'confirmed');

  if (confirmation.value.err) {
    throw new Error(`交易失败: ${JSON.stringify(confirmation.value.err)}`);
  }

  return signature;
}

注意这个细节: 我用了 sendRawTransaction 而不是 sendTransaction。这是因为 window.solana.signTransaction 返回的是签名后的 Transaction 对象,但 sendTransaction 方法在 v2.x 中接收的是序列化后的字节数组。所以我们需要调用 signedTransaction.serialize() 来获取 Uint8Array,然后用 sendRawTransaction 发送。

这里有个坑: 我一开始用 sendTransaction(signedTransaction),结果报错说"Transaction object is not serializable"。后来查文档才发现,sendTransaction 在 v2.x 中已经被重构了,它期望的参数是 Uint8ArrayBuffer,而不是 Transaction 对象。所以正确做法是 sendRawTransaction

第四步:处理确认状态和错误

确认交易时,confirmTransaction 返回的是一个 Promise<SignatureResult>,其中 SignatureResult 的结构是:

typescript 复制代码
{
  context: { slot: number },
  value: { err: object | null }
}

如果 err 不为 null,说明交易失败。但 err 可能是一个对象(如 { InstructionError: [0, "Custom"] }),也可能是一个字符串。所以不能简单用 if (err) 来判断,需要解析。

我写了一个辅助函数:

typescript 复制代码
function parseTransactionError(err: any): string {
  if (err === null) return '成功';
  if (typeof err === 'string') return err;
  if (err.InstructionError) {
    const [index, errorCode] = err.InstructionError;
    return `指令 ${index} 错误: ${errorCode}`;
  }
  return JSON.stringify(err);
}

这样在 UI 上就能显示具体的错误信息,而不是一个冰冷的"交易失败"。

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

下面是一个完整的 React 组件,集成了钱包连接和 SOL 转账功能。你可以直接复制到一个新的 React 项目中运行,只需要确保安装了 @solana/web3.js@2.0.0

tsx 复制代码
// App.tsx
import React, { useState } from 'react';
import { Connection, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { useWallet } from './hooks/useWallet';
import { sendSol } from './utils/sendTransaction';

// 使用 Devnet 节点
const connection = new Connection('https://api.devnet.solana.com', 'confirmed');

function App() {
  const { publicKey, connected, connect, disconnect } = useWallet();
  const [recipient, setRecipient] = useState('');
  const [amount, setAmount] = useState(0.01);
  const [status, setStatus] = useState('');

  const handleSend = async () => {
    if (!publicKey) return;
    setStatus('正在构建交易...');
    try {
      const toPubkey = new PublicKey(recipient);
      const signature = await sendSol(connection, publicKey, toPubkey, amount);
      setStatus(`交易成功!签名: ${signature}`);
    } catch (error) {
      setStatus(`交易失败: ${error.message}`);
    }
  };

  return (
    <div style={{ padding: '20px' }}>
      <h1>Solana 钱包交互示例</h1>
      {!connected ? (
        <button onClick={connect}>连接 Phantom 钱包</button>
      ) : (
        <div>
          <p>已连接: {publicKey.toBase58()}</p>
          <button onClick={disconnect}>断开连接</button>
          <div style={{ marginTop: '20px' }}>
            <input
              type="text"
              placeholder="接收地址 (Base58)"
              value={recipient}
              onChange={(e) => setRecipient(e.target.value)}
            />
            <input
              type="number"
              step="0.001"
              value={amount}
              onChange={(e) => setAmount(parseFloat(e.target.value))}
            />
            <button onClick={handleSend}>发送 SOL</button>
          </div>
          <p>{status}</p>
        </div>
      )}
    </div>
  );
}

export default App;

注意: 这个组件依赖上面写的 useWallet Hook 和 sendSol 函数。你需要创建对应的文件并导出。另外,window.solana 的类型定义需要安装 @solana/wallet-adapter-base 或手动声明:

typescript 复制代码
// global.d.ts
interface Window {
  solana: any;
}

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

  1. TypeError: Cannot read properties of undefined (reading 'signTransaction')

    • 原因:window.solana 未定义,因为 Phantom 没安装或未注入。
    • 解决:在 connect() 中加入 if (!window.solana) 的检查,并提示用户安装。
  2. Error: Transaction simulation failed: Invalid account data

    • 原因:我试图发送一个 NFT 铸造交易,但账户的 owner 不是预期的程序。
    • 解决:检查了 fromPubkey 是否正确,以及是否调用了正确的程序 ID。后来发现是账户数据格式问题,需要先获取账户的 accountInfo
  3. Error: 410 Gone: This RPC node is not available

    • 原因:使用了公共 RPC 节点,但请求频率过高被限流。
    • 解决:切换到开发网节点 https://api.devnet.solana.com,并在本地缓存区块哈希,减少不必要的 RPC 调用。
  4. Error: Transaction has already been processed

    • 原因:重复发送了同一笔交易(比如用户双击了提交按钮)。
    • 解决:在发送交易前禁用按钮,并使用 useRef 存储当前交易的 blockhash,避免重复。

小结

这次经历让我深刻体会到,Solana 和以太坊的前端开发思路完全不同。核心收获是:一定要理解 Solana 的交易模型------Transaction 是容器,Instruction 是操作,签名后序列化再发送。 同时,不要完全信任官方文档的示例,要结合版本号确认 API 是否已废弃。如果你想继续深挖,可以研究一下 @solana/web3.jsTransactionMessage API(v2.x 新引入的),它提供了一种更声明式的交易构建方式。

相关推荐
YouCanYouUp.11 小时前
掌控感心理学解析:人类最底层的心理需求
前端
wyc是xxs11 小时前
浏览器解析HTML头部的底层逻辑
前端·html
义嘉泰11 小时前
一颗 NAND Flash 的自我修养
前端·人工智能·芯片
liangdabiao11 小时前
【开源】利用Claude Agent SDK能力通过Skill自主构建完整的web
前端·开源
张元清11 小时前
驯服 React 里的 DOM 事件:useEventListener、useEventEmitter、useKeyModifier、useTextSelect
前端·javascript·面试
lichenyang45311 小时前
鸿蒙项目首页启动链路与 ArkUI 架构学习总结
前端
ssshooter11 小时前
Tauri 应用首次上架 App Store 被驳回了 3 次(iOS)和 12 轮(macOS)的经历
前端·ios·程序员
阿祖zu11 小时前
2026 企业级 Agent 产品落地思考与全流程指南
前端·程序员·aigc
yqcoder11 小时前
拆解互联网:通俗易懂的网络分层模型
前端·网络·网络协议