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

5 阅读1分钟

背景

上个月,团队决定试水Solana生态,开发一个简单的NFT铸造平台。作为一个有几年以太坊前端开发经验的“老兵”,我最初觉得这不过是换个链,把ethers.js换成@solana/web3.js而已。然而,从项目启动的第一天起,我就被现实狠狠教育了。在以太坊上,一个wagmi + viem的组合几乎能解决所有基础交互,生态成熟,文档丰富。但在Solana上,虽然@solana/web3.js是官方库,但其API设计思路和异步处理方式与以太坊的库有显著不同,很多我以为“理所当然”的操作,在这里都需要重新理解。

最开始的半天,我卡在了一个看似简单的问题上:如何可靠地连接用户的钱包,并获取其Solana地址和SOL余额? 在以太坊,这通常是一行useAccount钩子的事。但在Solana的世界里,我需要自己处理钱包适配器的选择、连接状态同步、以及如何与Connection对象配合。这篇文章,就是我解决从连接钱包到成功发送一笔简单转账交易这个完整流程的实战记录。

问题分析

我的最初思路很简单:参照官方示例,用@solana/wallet-adapter-react这套UI组件库快速集成。但很快我发现,示例代码只展示了“如何弹出连接按钮”,对于连接后如何获取一个可用的PublicKeyConnection实例进行后续操作,讲得比较模糊。

我遇到的第一个具体问题是:连接钱包后,我拿到了一个wallet.adapter.publicKey对象,但当我尝试用它去查询余额时,控制台报错“TypeError: Cannot read properties of null (reading ‘publicKey‘)”。我一度以为是钱包没连接成功,反复检查连接按钮的回调。后来通过断点调试才发现,钱包适配器的publicKey状态更新是异步的,而且受React组件生命周期影响。直接在执行连接操作的函数里同步访问publicKey,拿到的往往是null

这让我意识到,不能照搬以太坊那套同步思维。我需要建立一个清晰的数据流:1) 钱包连接事件触发 -> 2) 适配器状态更新 -> 3) React组件状态同步 -> 4) 使用最新的PublicKey发起链上查询。整个过程必须是响应式的。

核心实现

第一步:搭建项目基础与连接上下文

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

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

接下来,我按照官方模式,在应用最外层设置钱包上下文。这里有个关键点:需要明确指定集群。我一开始用了默认的devnet,但后来测试交易时发现需要SOL,又切换到了本地验证器,这个配置会直接影响后续的Connection对象。

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

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

function App() {
  // 可以切换网络:devnet, testnet, mainnet-beta,或自定义RPC URL
  const network = WalletAdapterNetwork.Devnet;
  const endpoint = clusterApiUrl(network); // 或者使用你的自定义RPC节点,如 `https://api.devnet.solana.com`

  // 支持的钱包列表
  const wallets = [new PhantomWalletAdapter()];

  return (
    <ConnectionProvider endpoint={endpoint}>
      <WalletProvider wallets={wallets} autoConnect>
        <WalletModalProvider>
          {/* 你的应用组件 */}
          <MyDemoComponent />
        </WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  );
}

export default App;

第二步:获取钱包状态与链连接对象

在业务组件内部,我需要获取当前连接的钱包和全局的链连接(Connection)对象。@solana/wallet-adapter-react 提供了几个非常有用的钩子。

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

export const MyDemoComponent: React.FC = () => {
  // 1. 获取连接对象,这是与Solana链交互的入口
  const { connection } = useConnection();
  
  // 2. 获取钱包状态,包含连接状态、公钥、发送交易等方法
  const { publicKey, connected, disconnect, sendTransaction } = useWallet();

  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);

  // 3. 一个获取余额的函数
  const fetchBalance = useCallback(async (pk: PublicKey) => {
    if (!connection) return;
    setLoading(true);
    try {
      // 查询余额,返回的单位是Lamports (1 SOL = 10^9 Lamports)
      const lamportsBalance = await connection.getBalance(pk);
      // 转换为SOL单位,方便显示
      const solBalance = lamportsBalance / LAMPORTS_PER_SOL;
      setBalance(solBalance);
    } catch (error) {
      console.error('获取余额失败:', error);
      setBalance(null);
    } finally {
      setLoading(false);
    }
  }, [connection]);

  // 4. 监听公钥变化,自动获取余额
  useEffect(() => {
    if (publicKey && connected) {
      console.log('当前连接地址:', publicKey.toString());
      fetchBalance(publicKey);
    } else {
      // 钱包未连接时清空余额
      setBalance(null);
    }
  }, [publicKey, connected, fetchBalance]);

  return (
    <div>
      <h2>Solana 钱包交互Demo</h2>
      {connected && publicKey ? (
        <div>
          <p><strong>钱包地址:</strong> {publicKey.toString()}</p>
          <p>
            <strong>SOL 余额:</strong> 
            {loading ? ' 查询中...' : ` ${balance !== null ? balance.toFixed(4) : '--'} SOL`}
          </p>
          <button onClick={disconnect}>断开连接</button>
        </div>
      ) : (
        <p>钱包未连接</p>
      )}
    </div>
  );
};

注意这个细节useConnectionuseWallet必须在ConnectionProviderWalletProvider的子组件内部调用,否则会报错。publicKey是一个PublicKey对象,不是字符串,需要调用.toString()方法显示。

第三步:构造并发送一笔转账交易

获取余额只是读取操作,更关键的是写入操作——发送交易。我想实现一个功能:让用户可以向一个指定地址发送0.01 SOL。这涉及到构造交易(Transaction)、添加指令(TransactionInstruction)、签名和发送。

// 在 MyDemoComponent.tsx 中添加状态和函数
import { SystemProgram, Transaction, TransactionSignature } from '@solana/web3.js';

export const MyDemoComponent: React.FC = () => {
  // ... 之前的 state 和 hooks ...
  const [recipientAddress, setRecipientAddress] = useState('');
  const [sending, setSending] = useState(false);
  const [txSignature, setTxSignature] = useState<TransactionSignature | null>(null);

  // 发送SOL的函数
  const handleSendSol = useCallback(async () => {
    if (!publicKey || !connection || !recipientAddress) {
      alert('请先连接钱包并填写接收地址');
      return;
    }

    let recipientPubkey: PublicKey;
    try {
      // 验证接收地址是否有效
      recipientPubkey = new PublicKey(recipientAddress);
    } catch (error) {
      alert('接收地址格式无效');
      return;
    }

    setSending(true);
    setTxSignature(null);

    try {
      // 1. 获取最近区块哈希,用于交易超时设置
      const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();

      // 2. 创建一笔新交易
      const transaction = new Transaction({
        feePayer: publicKey,
        blockhash,
        lastValidBlockHeight,
      });

      // 3. 创建转账指令
      const transferInstruction = SystemProgram.transfer({
        fromPubkey: publicKey,
        toPubkey: recipientPubkey,
        lamports: 0.01 * LAMPORTS_PER_SOL, // 转账金额,单位Lamports
      });

      // 4. 将指令添加到交易中
      transaction.add(transferInstruction);

      // 5. 发送交易并获取签名
      const signature = await sendTransaction(transaction, connection);
      setTxSignature(signature);
      console.log('交易已发送,签名:', signature);

      // 6. (可选) 确认交易
      const confirmation = await connection.confirmTransaction({
        signature,
        blockhash,
        lastValidBlockHeight,
      });
      if (confirmation.value.err) {
        throw new Error('交易确认失败');
      }
      console.log('交易已确认!');
      alert('转账成功!');

      // 7. 更新发送方余额
      fetchBalance(publicKey);

    } catch (error: any) {
      console.error('转账失败:', error);
      alert(`转账失败: ${error.message}`);
    } finally {
      setSending(false);
    }
  }, [publicKey, connection, recipientAddress, sendTransaction, fetchBalance]);

  // 在JSX中添加表单和按钮
  return (
    <div>
      {/* ... 之前的JSX ... */}
      {connected && publicKey && (
        <div style={{ marginTop: '20px' }}>
          <h3>发送 SOL</h3>
          <input
            type="text"
            placeholder="接收方Solana地址"
            value={recipientAddress}
            onChange={(e) => setRecipientAddress(e.target.value)}
            style={{ width: '400px', marginRight: '10px' }}
          />
          <button onClick={handleSendSol} disabled={sending}>
            {sending ? '发送中...' : '发送 0.01 SOL'}
          </button>
          {txSignature && (
            <p>
              <strong>交易成功!</strong> 
              <a 
                href={`https://explorer.solana.com/tx/${txSignature}?cluster=devnet`} 
                target="_blank" 
                rel="noopener noreferrer"
              >
                在浏览器中查看
              </a>
            </p>
          )}
        </div>
      )}
    </div>
  );
};

这里有个大坑:我最初构造交易时没有添加blockhashlastValidBlockHeight,导致交易一直发送失败,提示“Blockhash not found”。后来才明白,Solana的交易需要包含一个最近的有效区块哈希,作为交易的“有效期”标识。这个哈希必须通过connection.getLatestBlockhash()获取,并且不能重复使用,否则交易会被拒绝。

第四步:处理用户拒绝与交易模拟

在实际测试中,用户可能在钱包弹窗中拒绝签名,或者交易可能因为余额不足等原因失败。为了提高用户体验,我添加了交易模拟和更细致的错误处理。

// 修改 handleSendSol 函数中的 try-catch 块
try {
  const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
  const transaction = new Transaction({
    feePayer: publicKey,
    blockhash,
    lastValidBlockHeight,
  });
  const transferInstruction = SystemProgram.transfer({
    fromPubkey: publicKey,
    toPubkey: recipientPubkey,
    lamports: 0.01 * LAMPORTS_PER_SOL,
  });
  transaction.add(transferInstruction);

  // --- 新增:交易模拟,提前发现潜在错误 ---
  try {
    const simulationResult = await connection.simulateTransaction(transaction);
    if (simulationResult.value.err) {
      console.warn('交易模拟失败:', simulationResult.value.err);
      // 可以根据不同的错误类型给用户更具体的提示
      alert(`交易可能失败: ${JSON.stringify(simulationResult.value.err)}`);
      // 可以选择在此处直接返回,不发送真实交易
      // return;
    }
  } catch (simError) {
    console.warn('模拟交易时出错:', simError);
    // 模拟失败不一定代表真实交易会失败,可以继续
  }
  // --- 模拟结束 ---

  const signature = await sendTransaction(transaction, connection);
  setTxSignature(signature);
  // ... 后续确认代码 ...

} catch (error: any) {
  console.error('转账失败:', error);
  // 更友好的错误提示
  if (error.message.includes('User rejected')) {
    alert('您拒绝了交易签名。');
  } else if (error.message.includes('Insufficient funds')) {
    alert('余额不足,无法完成转账。');
  } else {
    alert(`转账失败: ${error.message}`);
  }
}

交易模拟(simulateTransaction)是一个非常有用的调试工具,它可以在不消耗真实SOL的情况下,在本地执行交易并返回可能的结果或错误。这对于在发送前验证交易逻辑(如权限、余额)非常有帮助。

完整代码

以下是一个整合了所有功能的、可运行的React组件示例。请确保它被包裹在正确的Provider中(如背景部分所示)。

// components/SolanaTransferDemo.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import {
  LAMPORTS_PER_SOL,
  PublicKey,
  SystemProgram,
  Transaction,
  TransactionSignature,
} from '@solana/web3.js';

export const SolanaTransferDemo: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, connected, disconnect, sendTransaction } = useWallet();

  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const [recipientAddress, setRecipientAddress] = useState('');
  const [sending, setSending] = useState(false);
  const [txSignature, setTxSignature] = useState<TransactionSignature | null>(null);

  // 获取余额
  const fetchBalance = useCallback(async (pk: PublicKey) => {
    if (!connection) return;
    setLoading(true);
    try {
      const lamportsBalance = await connection.getBalance(pk);
      const solBalance = lamportsBalance / LAMPORTS_PER_SOL;
      setBalance(solBalance);
    } catch (error) {
      console.error('获取余额失败:', error);
      setBalance(null);
    } finally {
      setLoading(false);
    }
  }, [connection]);

  // 监听钱包连接状态变化
  useEffect(() => {
    if (publicKey && connected) {
      console.log('当前连接地址:', publicKey.toString());
      fetchBalance(publicKey);
    } else {
      setBalance(null);
    }
  }, [publicKey, connected, fetchBalance]);

  // 发送SOL
  const handleSendSol = useCallback(async () => {
    if (!publicKey || !connection || !recipientAddress) {
      alert('请先连接钱包并填写接收地址');
      return;
    }

    let recipientPubkey: PublicKey;
    try {
      recipientPubkey = new PublicKey(recipientAddress);
    } catch (error) {
      alert('接收地址格式无效');
      return;
    }

    setSending(true);
    setTxSignature(null);

    try {
      const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
      const transaction = new Transaction({
        feePayer: publicKey,
        blockhash,
        lastValidBlockHeight,
      });

      const transferInstruction = SystemProgram.transfer({
        fromPubkey: publicKey,
        toPubkey: recipientPubkey,
        lamports: 0.01 * LAMPORTS_PER_SOL,
      });
      transaction.add(transferInstruction);

      // 交易模拟(可选)
      const simulationResult = await connection.simulateTransaction(transaction);
      if (simulationResult.value.err) {
        console.warn('交易模拟提示:', simulationResult.value.err);
      }

      const signature = await sendTransaction(transaction, connection);
      setTxSignature(signature);
      console.log('交易已发送,签名:', signature);

      // 等待交易确认
      const confirmation = await connection.confirmTransaction({
        signature,
        blockhash,
        lastValidBlockHeight,
      });
      if (confirmation.value.err) {
        throw new Error('交易确认失败');
      }
      console.log('交易已确认!');
      alert('转账成功!');

      // 更新余额
      fetchBalance(publicKey);

    } catch (error: any) {
      console.error('转账失败:', error);
      if (error.message.includes('User rejected')) {
        alert('您拒绝了交易签名。');
      } else if (error.message.includes('Insufficient funds')) {
        alert('余额不足,无法完成转账。');
      } else {
        alert(`转账失败: ${error.message}`);
      }
    } finally {
      setSending(false);
    }
  }, [publicKey, connection, recipientAddress, sendTransaction, fetchBalance]);

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h1>Solana 前端交互实战</h1>
      
      <div style={{ marginBottom: '30px', padding: '15px', border: '1px solid #ccc', borderRadius: '8px' }}>
        <h2>钱包状态</h2>
        {connected && publicKey ? (
          <div>
            <p><strong>🟢 已连接</strong></p>
            <p><strong>地址:</strong> {publicKey.toString()}</p>
            <p>
              <strong>余额:</strong> 
              {loading ? ' 查询中...' : ` ${balance !== null ? balance.toFixed(4) : '--'} SOL`}
            </p>
            <button onClick={disconnect} style={{ padding: '8px 16px', cursor: 'pointer' }}>
              断开连接
            </button>
          </div>
        ) : (
          <p>🔴 钱包未连接 (请点击页面右上角的“选择钱包”按钮进行连接)</p>
        )}
      </div>

      {connected && publicKey && (
        <div style={{ padding: '15px', border: '1px solid #ccc', borderRadius: '8px' }}>
          <h2>发送 SOL 测试</h2>
          <div style={{ marginBottom: '15px' }}>
            <label>
              接收地址: <br/>
              <input
                type="text"
                placeholder="例如: 7F...(输入一个有效的Solana地址)"
                value={recipientAddress}
                onChange={(e) => setRecipientAddress(e.target.value)}
                style={{ width: '100%', maxWidth: '500px', padding: '8px', marginTop: '5px' }}
              />
            </label>
          </div>
          <button 
            onClick={handleSendSol} 
            disabled={sending || !recipientAddress}
            style={{ 
              padding: '10px 20px', 
              backgroundColor: sending ? '#ccc' : '#007bff', 
              color: 'white', 
              border: 'none', 
              borderRadius: '4px', 
              cursor: (sending || !recipientAddress) ? 'not-allowed' : 'pointer' 
            }}
          >
            {sending ? '发送中...' : '发送 0.01 SOL'}
          </button>
          
          {txSignature && (
            <div style={{ marginTop: '15px', padding: '10px', backgroundColor: '#f0f9ff', borderRadius: '4px' }}>
              <p><strong>✅ 交易成功!</strong></p>
              <p>交易签名: <code style={{ fontSize: '0.9em' }}>{txSignature.slice(0, 20)}...</code></p>
              <a 
                href={`https://explorer.solana.com/tx/${txSignature}?cluster=devnet`} 
                target="_blank" 
                rel="noopener noreferrer"
                style={{ color: '#007bff' }}
              >
                在 Solana Explorer 上查看详情
              </a>
            </div>
          )}
        </div>
      )}
    </div>
  );
};

踩坑记录

  1. “TypeError: Cannot read properties of null (reading ‘publicKey’)”

    • 问题:在钱包连接回调函数中立即访问wallet.adapter.publicKey
    • 原因:钱包状态更新是异步的,React状态尚未同步。
    • 解决:使用useWallet钩子提供的publicKey状态,并利用useEffect监听其变化。
  2. “Blockhash not found” 或交易发送失败

    • 问题:构造Transaction时没有提供有效的blockhashlastValidBlockHeight
    • 原因:Solana交易需要引用一个最近的区块哈希,且该哈希有过期时间。
    • 解决:在发送每笔交易前,调用connection.getLatestBlockhash()获取最新的哈希和高度,并填入交易构造参数。
  3. 交易一直处于“待确认”状态,confirmTransaction超时

    • 问题:在开发环境(尤其是本地验证器)下,网络可能不稳定,或者使用的blockhash已经过期。
    • 原因confirmTransaction等待的区块高度可能永远不会到达。
    • 解决:a) 确保使用getLatestBlockhash获取最新的哈希。b) 为confirmTransaction设置合理的超时时间或重试逻辑。c) 对于非关键操作,可以考虑不等待确认,仅通过交易签名在链上浏览器查询结果。
  4. “400 Bad Request” 或 RPC 节点连接问题

    • 问题:使用默认的clusterApiUrl(‘devnet’)有时响应慢或失败。
    • 原因:公共RPC节点有速率限制或暂时不可用。
    • 解决:注册并使用一个免费的私有RPC节点服务(如 Helius, QuickNode, Triton),将endpoint替换为提供的私有URL,稳定性和速度会大幅提升。

小结

从零开始搞定Solana前端基础交互,核心是理解其异步、区块哈希驱动的交易模型。@solana/web3.js功能强大但更偏底层,结合wallet-adapter系列库能高效搭建应用。下一步可以深入研究如何与SPL代币(如USDC)交互、如何解析链上账户数据,以及如何优化交易确认的用户体验。