Solana DApp 开发踩坑实录:从零用 @solana/web3.js 实现链上数据查询与交易签名

背景

两个月前,我们团队接了一个 Solana 上的 NFT 市场项目。我主要负责前端,需要实现:用户连接钱包后,能查看自己拥有的 NFT 列表,以及点击"购买"按钮后,通过钱包签名完成交易。

我的问题是:之前所有 Web3 前端经验都在 EVM 链上(以太坊、Polygon),用 ethers.js 和 wagmi 很顺手。但 Solana 的账户模型和交易结构完全不同------没有合约地址、没有 ABI、没有 call 和 send 的概念,连钱包连接方式都不一样。

我当时拿到需求后,第一反应是:Solana 前端开发到底怎么搞?@solana/web3.js 这个库到底怎么用?文档看起来挺全,但一上手就发现,很多细节文档没说清楚,或者说了但我没看懂。

这篇文章就是我完整解决问题的过程记录。如果你也是从 EVM 链转到 Solana 的前端开发者,这篇文章应该能帮你省下至少两天踩坑时间。

问题分析:我最初的想法为什么行不通?

我的第一个想法是:直接调用 Solana RPC,传一个账户地址,就能拿到它的所有数据------就像 ethers.js 里用 provider.getBalance(address) 一样简单。

于是我写了这样一段代码:

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

const connection = new Connection('https://api.mainnet-beta.solana.com');
const publicKey = new PublicKey('用户钱包地址');
const balance = await connection.getBalance(publicKey);

这个确实能拿到 SOL 余额。但问题来了:我需要的是 NFT 列表,也就是用户拥有的 SPL Token(Solana 上的代币标准)。getBalance 只返回原生 SOL,不返回代币。

然后我找到了 getTokenAccountsByOwner 这个方法:

typescript 复制代码
const tokenAccounts = await connection.getTokenAccountsByOwner(publicKey, {
  programId: TOKEN_PROGRAM_ID
});

这个方法返回的是一个数组,每个元素包含 pubkeyaccount 两个字段。account.data 是一段 Buffer,需要反序列化。

这里有个大坑 :我当时直接打印 tokenAccounts,发现 account.data 是一串看不懂的十六进制数。我心想:这怎么解析?难道要手动按字节拆?

后来查了文档才知道,Solana 的账户数据是 Borsh 编码的,需要用一个叫 AccountLayout 的类来解析。而且这个 AccountLayout@solana/spl-token 包里,不是 @solana/web3.js 自带的。

所以我的第一个教训是:Solana 的账户数据不是 JSON,是二进制编码,必须用对应的 Layout 来解码

核心实现

1. 搭建基础环境:连接钱包和 RPC

首先,我选择了 @solana/wallet-adapter-react@solana/wallet-adapter-react-ui 这套官方推荐的钱包连接方案。它和 wagmi 类似,提供了 React Context 和 Hook。

安装依赖:

bash 复制代码
npm install @solana/web3.js @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets @solana/spl-token

然后创建 SolanaProvider

typescript 复制代码
// SolanaProvider.tsx
import { useMemo } from 'react';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter, SolflareWalletAdapter } from '@solana/wallet-adapter-wallets';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { clusterApiUrl } from '@solana/web3.js';

// 必须引入样式
import '@solana/wallet-adapter-react-ui/styles.css';

export function SolanaProvider({ children }: { children: React.ReactNode }) {
  const network = WalletAdapterNetwork.Devnet;
  const endpoint = useMemo(() => clusterApiUrl(network), [network]);
  
  // 支持的 wallet 列表
  const wallets = useMemo(
    () => [
      new PhantomWalletAdapter(),
      new SolflareWalletAdapter(),
    ],
    [network]
  );

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

注意这个细节clusterApiUrl 自动生成 Devnet 的 RPC 地址,但生产环境建议用自己的 RPC 节点或第三方服务(如 Helius、QuickNode),因为默认公共 RPC 有速率限制,很容易 429。

2. 查询用户持有的 SPL Token(NFT)

接下来是核心功能:查询用户钱包中所有 SPL Token,并过滤出 NFT(即代币数量为 1 且不可分割的)。

typescript 复制代码
// hooks/useUserTokens.ts
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { PublicKey } from '@solana/web3.js';
import { getAccount, getMint, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token';
import { useEffect, useState } from 'react';

interface TokenInfo {
  mint: string;
  amount: string;
  decimals: number;
  isNft: boolean;
}

export function useUserTokens() {
  const { connection } = useConnection();
  const { publicKey } = useWallet();
  const [tokens, setTokens] = useState<TokenInfo[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!publicKey) return;
    
    const fetchTokens = async () => {
      setLoading(true);
      try {
        // 获取用户所有的代币账户
        const tokenAccounts = await connection.getTokenAccountsByOwner(
          publicKey,
          { programId: TOKEN_2022_PROGRAM_ID } // 注意:SPL Token 2022 标准用这个 Program ID
        );
        
        const tokenInfos: TokenInfo[] = [];
        
        for (const { pubkey, account } of tokenAccounts.value) {
          // 解析账户数据
          const tokenAccount = getAccount(account, TOKEN_2022_PROGRAM_ID);
          
          // 获取 mint 信息
          const mintInfo = await getMint(connection, tokenAccount.mint);
          
          tokenInfos.push({
            mint: tokenAccount.mint.toBase58(),
            amount: tokenAccount.amount.toString(),
            decimals: mintInfo.decimals,
            isNft: mintInfo.decimals === 0 && tokenAccount.amount === BigInt(1),
          });
        }
        
        setTokens(tokenInfos);
      } catch (error) {
        console.error('获取代币失败:', error);
      } finally {
        setLoading(false);
      }
    };
    
    fetchTokens();
  }, [publicKey, connection]);

  return { tokens, loading };
}

这里有个坑 :我一开始用了 TOKEN_PROGRAM_ID(旧版 SPL Token),结果发现很多新发行的 NFT 都查不到。后来才知道,现在 Solana 官方推荐使用 Token-2022 标准,它的 Program ID 是 TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb。如果你只查旧版,会漏掉大部分数据。

另一个坑是:getAccount 函数需要传入 Buffer 类型的 account.data,而不是直接传 account 对象。我一开始传错了,报错信息是 Invalid account data,排查了半小时才发现。

3. 构建并签名交易:实现"购买"功能

查询到 NFT 之后,下一步是实现购买交易。在 Solana 上,交易由指令(Instruction)组成,每个指令对应一个程序调用。

以购买 NFT 为例,假设我们调用一个市场合约的 buy 指令:

typescript 复制代码
// hooks/useBuyNft.ts
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { Transaction, SystemProgram, PublicKey, TransactionInstruction } from '@solana/web3.js';

export function useBuyNft() {
  const { connection } = useConnection();
  const { publicKey, sendTransaction } = useWallet();

  const buyNft = async (nftMint: string, price: number) => {
    if (!publicKey) throw new Error('钱包未连接');

    // 1. 构建交易
    const transaction = new Transaction();
    
    // 2. 添加指令:这里假设有一个市场合约,其 buy 指令需要传入 NFT mint 和价格
    // 实际项目中,你需要知道市场合约的 Program ID 和指令布局
    const buyInstruction = new TransactionInstruction({
      keys: [
        { pubkey: publicKey, isSigner: true, isWritable: true },  // 买家
        { pubkey: new PublicKey(nftMint), isSigner: false, isWritable: true },  // NFT mint
        { pubkey: new PublicKey('市场合约地址'), isSigner: false, isWritable: true },  // 市场合约
      ],
      programId: new PublicKey('市场合约 Program ID'),  // 这是假的,需要替换
      data: Buffer.from([]),  // 指令数据,需要按合约规范编码
    });
    
    transaction.add(buyInstruction);

    // 3. 设置最近区块哈希(必须!)
    const { blockhash } = await connection.getLatestBlockhash();
    transaction.recentBlockhash = blockhash;
    transaction.feePayer = publicKey;

    // 4. 发送交易并让钱包签名
    const signature = await sendTransaction(transaction, connection);
    
    // 5. 等待确认
    await connection.confirmTransaction(signature, 'confirmed');
    
    return signature;
  };

  return { buyNft };
}

注意这个细节 :Solana 的交易必须设置 recentBlockhash,否则会被网络拒绝。这个 blockhash 有 150 个 slot 的生命周期(约 1-2 分钟),过期后需要重新获取。我一开始忘了设这个,交易一直报 Blockhash not found 错误,排查了好久。

另外,sendTransaction@solana/wallet-adapter-react 提供的 Hook,它会自动弹出钱包签名界面。如果你用 Phantom 或其他钱包,它会自动处理。

4. 模拟交易:先试错再提交

在实际发送交易之前,强烈建议先用 simulateTransaction 模拟执行,避免因为参数错误导致 gas 浪费(Solana 虽然 gas 低,但也会扣)。

typescript 复制代码
// 在发送之前模拟
const simulationResult = await connection.simulateTransaction(transaction);
if (simulationResult.value.err) {
  console.error('模拟交易失败:', simulationResult.value.err);
  throw new Error(`交易模拟失败: ${simulationResult.value.err}`);
}
// 模拟成功后再发送
const signature = await sendTransaction(transaction, connection);

这里有个坑:模拟交易时,如果指令中包含了需要签名的账户(isSigner: true),但模拟时钱包没有实际签名,会导致模拟失败。所以模拟只能检查逻辑错误,不能完全替代实际签名。

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

下面是一个完整的组件,它实现了连接钱包、查询 NFT、购买 NFT 的完整流程。

typescript 复制代码
// App.tsx
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { useUserTokens } from './hooks/useUserTokens';
import { useBuyNft } from './hooks/useBuyNft';

function App() {
  const { publicKey } = useWallet();
  const { tokens, loading } = useUserTokens();
  const { buyNft } = useBuyNft();

  const handleBuy = async (mint: string) => {
    try {
      const tx = await buyNft(mint, 0.1); // 假设价格 0.1 SOL
      alert(`购买成功,交易签名: ${tx}`);
    } catch (error) {
      console.error('购买失败:', error);
      alert('购买失败,请查看控制台错误');
    }
  };

  return (
    <div style={{ padding: '2rem' }}>
      <h1>Solana NFT 市场</h1>
      <WalletMultiButton />
      
      {publicKey && (
        <div>
          <h2>你的 NFT 列表</h2>
          {loading && <p>加载中...</p>}
          {!loading && tokens.length === 0 && <p>没有找到 NFT</p>}
          <ul>
            {tokens.filter(t => t.isNft).map(token => (
              <li key={token.mint}>
                Mint: {token.mint}
                <button onClick={() => handleBuy(token.mint)}>购买</button>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

export default App;

踩坑记录

  1. Blockhash not found 错误

    • 现象:发送交易后返回 Blockhash not found
    • 原因:没有设置 transaction.recentBlockhash
    • 解决:调用 connection.getLatestBlockhash() 获取最新 blockhash 并赋值。
  2. Invalid account data 错误

    • 现象:调用 getAccount 时报错。
    • 原因:传入了错误的参数格式,getAccount 需要 Buffer 类型。
    • 解决:确保传入 account.data(类型为 Buffer),而不是整个 account 对象。
  3. 查不到 NFT 数据

    • 现象:getTokenAccountsByOwner 返回空数组。
    • 原因:使用了旧版 TOKEN_PROGRAM_ID,而 NFT 是 Token-2022 标准。
    • 解决:改用 TOKEN_2022_PROGRAM_ID
  4. 模拟交易成功但实际交易失败

    • 现象:模拟通过,但 sendTransaction 后报错。
    • 原因:模拟时没有检查签名,实际签名时钱包拒绝或参数变化。
    • 解决:在模拟后立即发送,避免 blockhash 过期;同时检查钱包是否已授权。

小结

Solana 前端开发和 EVM 链有本质不同:账户数据是二进制编码需要反序列化、交易必须设置 blockhash、Token 标准有新旧之分。核心收获是:不要用 EVM 的思维去套 Solana,老老实实看文档和 SDK 的 API 签名。如果想深入,可以研究 Solana 的 PDA(程序派生地址)和跨程序调用(CPI),这是 Solana 开发的高级特性。

相关推荐
摸着石头过河的石头1 小时前
从 Webpack 到 RSBuild:前端构建工具的进化之路
前端
疯狂的魔鬼1 小时前
告别 boolean 地狱:一个文件上传组件的状态机实践
前端·设计
狂师1 小时前
测试工程师的AI 技能库:推荐5个让你效率翻倍的Skills
前端·后端·测试
李明卫杭州1 小时前
Vue3 watch 与 watchEffect 深度解析
前端
CodeSheep1 小时前
DeepSeek正式官宣摇人,夯!
前端·后端·程序员
用户059540174461 小时前
Redis持久化踩坑实录:这个数据丢失Bug让我排查了6小时
前端·css
用户2136610035721 小时前
VueRouter进阶-动态路由与嵌套路由
前端·vue.js
梯度不陡1 小时前
Signal #17:Agent 开始进入组织系统
前端·javascript
何智超1 小时前
AI 微前端性能优化之旅(上):复盘
前端·vibecoding