从零到一:我在Solana NFT铸造前端中搞定@solana/web3.js连接与交易

20 阅读1分钟

背景

上个月,团队决定开拓新链,启动了一个基于Solana的NFT铸造项目。作为团队里Web3前端经验相对丰富的,我自然被分配了搭建前端DApp的任务。我之前主要深耕以太坊和EVM兼容链,对ethers.jswagmi那一套滚瓜烂熟,心想换个链的SDK能有多难?结果,从熟悉的ethers.providers.Web3Provider切换到@solana/web3.jsConnection类,从MetaMask切换到Phantom钱包,这一路的“水土不服”让我踩的坑比预想的多得多。我的首要目标很简单:让用户能用Phantom钱包连接,并正确显示其SOL余额。

问题分析

一开始,我试图沿用EVM链的思维模式。在以太坊上,流程通常是:注入的window.ethereum -> new ethers.providers.Web3Provider() -> 获取账号和余额。我查了@solana/web3.js的文档,发现核心是Connection(连接节点)和PublicKey(地址)。我的初步思路是:

  1. 检测Phantom钱包(window.solana)。
  2. 连接钱包,获取公钥(PublicKey)。
  3. Connection查询该公钥的余额。

听起来很直接,但我马上遇到了第一个拦路虎:连接钱包后,余额始终为0。我确认了钱包里有SOL,RPC节点也换了好几个(devnet, mainnet-beta的公共节点)。排查后发现,问题出在两个地方:一是对Solana余额单位(lamports vs SOL)的转换不熟悉,二是没有正确处理钱包连接和状态变化的异步事件。这让我意识到,不能简单照搬EVM的模式,得从头理解Solana前端的交互逻辑。

核心实现

1. 环境搭建与钱包检测

首先,创建一个React + TypeScript项目,并安装核心依赖:

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

这里有个关键点:单纯用@solana/web3.js也能直接操作window.solana,但社区更推荐使用@solana/wallet-adapter-*这一套工具库。它提供了React上下文、钩子和一套标准的UI组件,能更好地管理钱包状态、支持多钱包,并处理了大量底层细节。我决定采用这个推荐方案,避免重复造轮子。

钱包检测和连接的核心逻辑,我们封装在自定义钩子或上下文中。但首先,要在应用根组件进行配置。

2. 配置钱包上下文与连接节点

App.tsx或主组件中,我们需要设置钱包适配器和提供连接。

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

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

function App() {
  // 配置网络。这里以开发网为例,上线需切主网
  const network = WalletAdapterNetwork.Devnet;
  // 使用Memoized,避免每次渲染都创建新的endpoint和wallets实例
  const endpoint = useMemo(() => clusterApiUrl(network), [network]);
  
  const wallets = useMemo(
    () => [
      new PhantomWalletAdapter(),
      // 可以添加其他钱包适配器,如Solflare
    ],
    []
  );

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

export default App;

注意这个细节ConnectionProviderendpoint参数非常重要。公共节点可能有速率限制或不稳定,对于生产环境,强烈建议使用付费的RPC服务(如QuickNode, Helius)提供的专属节点URL,这能极大提升连接稳定性和查询速度。

3. 连接钱包与获取余额

接下来,在具体的组件MyWalletComponent中,我们使用适配器提供的钩子来操作钱包和获取数据。

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

export const MyWalletComponent: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, connected } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);

  // 效果:当钱包连接状态或公钥变化时,获取余额
  useEffect(() => {
    const fetchBalance = async () => {
      if (!connection || !publicKey) {
        setBalance(null);
        return;
      }
      setLoading(true);
      try {
        // 这里有个坑:getBalance返回的是lamports(1 SOL = 10^9 lamports)
        const lamportsBalance = await connection.getBalance(publicKey);
        // 转换为SOL单位
        const solBalance = lamportsBalance / LAMPORTS_PER_SOL;
        setBalance(solBalance);
      } catch (error) {
        console.error('获取余额失败:', error);
        setBalance(null);
      } finally {
        setLoading(false);
      }
    };

    fetchBalance();
    // 可以设置一个定时器来轮询余额,但对于实时性要求高的,建议用websocket订阅
  }, [connection, publicKey]); // 依赖项:连接对象和公钥

  return (
    <div>
      <WalletMultiButton />
      {connected && publicKey ? (
        <div>
          <p>钱包地址: {publicKey.toBase58()}</p>
          {loading ? (
            <p>查询余额中...</p>
          ) : (
            <p>余额: {balance !== null ? `${balance.toFixed(4)} SOL` : '--'}</p>
          )}
        </div>
      ) : (
        <p>请连接钱包</p>
      )}
    </div>
  );
};

这里有个大坑connection.getBalance(publicKey)返回的是number类型的lamports,而不是SOL。直接显示这个数字会让人误以为余额极小。必须除以LAMPORTS_PER_SOL(一个常量,值为1_000_000_000)来转换。这是我一开始显示余额为0的罪魁祸首之一(因为我的devnet账户余额是2 SOL,显示为2_000_000_000 lamports,我误以为是0)。

4. 构造并发送一笔简单的转账交易

显示余额之后,下一步自然是想让用户能操作。我们实现一个简单的SOL转账功能。

// 在MyWalletComponent中添加状态和函数
import { SystemProgram, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
// ... 其他导入

export const MyWalletComponent: React.FC = () => {
  // ... 之前的 states 和 hooks
  const [recipient, setRecipient] = useState('');
  const [amount, setAmount] = useState('');
  const [sending, setSending] = useState(false);

  const handleSendSol = async () => {
    if (!publicKey || !recipient || !amount) {
      alert('请填写完整信息');
      return;
    }
    const lamports = parseFloat(amount) * LAMPORTS_PER_SOL;
    if (isNaN(lamports) || lamports <= 0) {
      alert('请输入有效的金额');
      return;
    }

    setSending(true);
    try {
      // 1. 创建交易对象
      const transaction = new Transaction();
      
      // 2. 添加转账指令
      const transferInstruction = SystemProgram.transfer({
        fromPubkey: publicKey,
        toPubkey: new PublicKey(recipient),
        lamports,
      });
      transaction.add(transferInstruction);

      // 3. 获取最近的区块哈希(Recent Blockhash),这是Solana交易必需的
      const { blockhash } = await connection.getRecentBlockhash();
      transaction.recentBlockhash = blockhash;
      // 设置付费方(fee payer)
      transaction.feePayer = publicKey;

      // 4. 发送交易并等待确认
      // 注意:这里需要钱包适配器来签名,不能直接用sendAndConfirmTransaction
      // 我们先获取签名,然后发送
      const signature = await sendTransaction(transaction, connection);
      
      // 5. 等待确认(可选,对于快速反馈,可以只等“预确认”)
      await connection.confirmTransaction(signature, 'confirmed');
      alert(`转账成功!交易哈希: ${signature}`);
      // 成功后刷新余额
      const newBalance = await connection.getBalance(publicKey);
      setBalance(newBalance / LAMPORTS_PER_SOL);
      setRecipient('');
      setAmount('');
    } catch (error: any) {
      console.error('转账失败:', error);
      alert(`转账失败: ${error.message}`);
    } finally {
      setSending(false);
    }
  };

  // 注意:我们需要从useWallet钩子中解构出sendTransaction函数
  const { sendTransaction } = useWallet();

  return (
    <div>
      {/* ... 之前的连接和余额显示代码 */}
      {connected && (
        <div>
          <h3>转账SOL</h3>
          <input
            type="text"
            placeholder="接收方地址"
            value={recipient}
            onChange={(e) => setRecipient(e.target.value)}
          />
          <input
            type="number"
            step="any"
            placeholder="金额 (SOL)"
            value={amount}
            onChange={(e) => setAmount(e.target.value)}
          />
          <button onClick={handleSendSol} disabled={sending}>
            {sending ? '发送中...' : '发送'}
          </button>
        </div>
      )}
    </div>
  );
};

这里有个至关重要的区别:在EVM链,我们通常用signer.sendTransaction(tx)一步完成签名和发送。而在Solana,构造交易(Transaction)和签名/发送是分离的。我们先用@solana/web3.js构造一个包含指令(Instruction)和必要元数据(blockhash, feePayer)的交易对象,然后通过钱包适配器提供的sendTransaction方法,将交易对象交给钱包(如Phantom)去签名并发送到网络。这是Solana交易模型的一个核心特点。

完整代码

以下是一个整合后的、可直接运行的简化版App.tsx,展示了完整的连接、查余额、转账流程。

// App.tsx
import React, { useMemo, useState, useEffect } from 'react';
import { ConnectionProvider, WalletProvider, useConnection, useWallet } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter } from '@solana/wallet-adapter-phantom';
import { WalletModalProvider, WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { clusterApiUrl, PublicKey, SystemProgram, Transaction, LAMPORTS_PER_SOL } from '@solana/web3.js';
import '@solana/wallet-adapter-react-ui/styles.css';

// 主应用包装器
function AppWrapper() {
  const network = WalletAdapterNetwork.Devnet;
  const endpoint = useMemo(() => clusterApiUrl(network), [network]);
  const wallets = useMemo(() => [new PhantomWalletAdapter()], []);

  return (
    <ConnectionProvider endpoint={endpoint}>
      <WalletProvider wallets={wallets} autoConnect>
        <WalletModalProvider>
          <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
            <h1>Solana Web3.js 入门实战</h1>
            <WalletDemo />
          </div>
        </WalletModalProvider>
      </WalletProvider>
    </ConnectionProvider>
  );
}

// 主要演示组件
function WalletDemo() {
  const { connection } = useConnection();
  const { publicKey, connected, sendTransaction } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const [recipient, setRecipient] = useState('');
  const [amount, setAmount] = useState('');
  const [sending, setSending] = useState(false);

  // 获取余额
  useEffect(() => {
    const updateBalance = async () => {
      if (!connection || !publicKey) {
        setBalance(null);
        return;
      }
      setLoading(true);
      try {
        const lamports = await connection.getBalance(publicKey);
        setBalance(lamports / LAMPORTS_PER_SOL);
      } catch (err) {
        console.error(err);
        setBalance(null);
      } finally {
        setLoading(false);
      }
    };
    updateBalance();
  }, [connection, publicKey]);

  // 处理转账
  const handleSend = async () => {
    if (!publicKey || !recipient || !amount || !sendTransaction) return;
    const lamports = parseFloat(amount) * LAMPORTS_PER_SOL;
    if (isNaN(lamports) || lamports <= 0) {
      alert('Invalid amount');
      return;
    }

    setSending(true);
    try {
      const transaction = new Transaction();
      transaction.add(
        SystemProgram.transfer({
          fromPubkey: publicKey,
          toPubkey: new PublicKey(recipient),
          lamports,
        })
      );

      const { blockhash } = await connection.getRecentBlockhash();
      transaction.recentBlockhash = blockhash;
      transaction.feePayer = publicKey;

      const signature = await sendTransaction(transaction, connection);
      console.log('Transaction signature:', signature);
      // 等待确认,可根据需求调整确认级别('processed', 'confirmed', 'finalized')
      await connection.confirmTransaction(signature, 'confirmed');
      alert(`Sent ${amount} SOL to ${recipient}! Tx: ${signature}`);

      // 刷新余额
      const newLamports = await connection.getBalance(publicKey);
      setBalance(newLamports / LAMPORTS_PER_SOL);
      setRecipient('');
      setAmount('');
    } catch (error: any) {
      console.error('Send failed:', error);
      alert(`Send failed: ${error.message}`);
    } finally {
      setSending(false);
    }
  };

  return (
    <div>
      <div style={{ marginBottom: '20px' }}>
        <WalletMultiButton />
      </div>

      {connected && publicKey ? (
        <div>
          <p>
            <strong>Address:</strong> {publicKey.toBase58().slice(0, 8)}...
          </p>
          <p>
            <strong>Balance:</strong>{' '}
            {loading ? 'Loading...' : balance !== null ? `${balance.toFixed(4)} SOL` : '--'}
          </p>

          <div style={{ marginTop: '30px', borderTop: '1px solid #ccc', paddingTop: '20px' }}>
            <h3>Transfer SOL</h3>
            <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '400px' }}>
              <input
                type="text"
                placeholder="Recipient Public Key"
                value={recipient}
                onChange={(e) => setRecipient(e.target.value)}
                style={{ padding: '8px' }}
              />
              <input
                type="number"
                step="any"
                placeholder="Amount (SOL)"
                value={amount}
                onChange={(e) => setAmount(e.target.value)}
                style={{ padding: '8px' }}
              />
              <button onClick={handleSend} disabled={sending} style={{ padding: '10px' }}>
                {sending ? 'Sending...' : 'Send'}
              </button>
            </div>
            <p style={{ fontSize: '0.9em', color: '#666', marginTop: '10px' }}>
              <small>Use devnet SOL for testing. Get some from a faucet.</small>
            </p>
          </div>
        </div>
      ) : (
        <p>Connect your wallet to get started.</p>
      )}
    </div>
  );
}

export default AppWrapper;

踩坑记录

  1. 余额显示为0或极小值:这是最经典的坑。connection.getBalance()返回的是lamports,我没做转换就直接显示。解决方法:牢记 SOL = lamports / LAMPORTS_PER_SOL
  2. 交易发送失败:Missing recent blockhash:构造交易对象Transaction后,没有设置recentBlockhashfeePayer属性就直接发送。解决方法:必须在发送前调用connection.getRecentBlockhash()获取,并赋值给transaction.recentBlockhash,同时明确指定transaction.feePayer
  3. Phantom钱包弹窗连接后,状态没更新:直接监听window.solanaconnect事件,但React状态管理混乱。解决方法:使用@solana/wallet-adapter-react提供的useWallet钩子,它封装了状态管理,connectedpublicKey状态会自动更新。
  4. sendTransaction is not a function:我试图直接从@solana/web3.js导入sendAndConfirmTransaction并传入交易对象,但这需要私钥签名者。在浏览器前端,私钥由钱包保管。解决方法:使用从useWallet()钩子解构出来的sendTransaction方法,它将交易发送到钱包扩展进行签名。

小结

这一趟下来,我最大的收获是理解了Solana前端交互的“范式转换”:从EVM的Provider/Signer模型,转向Solana的Connection/Transaction/Wallet Adapter模型。核心在于明确职责分离:前端构造交易,钱包负责签名。掌握了连接、查余额、转账这三板斧,就算在Solana前端开发中站稳了脚跟。接下来,可以继续深挖如何与智能合约(Solana叫Program)交互,比如调用一个NFT铸造的指令,那又会涉及到不同的指令构造和账户(Account)管理,将是下一个有趣的挑战。