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

背景

上个月,团队接了一个Solana生态的NFT项目,需要开发一个允许用户连接钱包、查看余额并铸造NFT的前端界面。作为一个在以太坊和EVM兼容链上摸爬滚打了五年的前端,我的工具箱里装满了ethers.jsviemwagmi。当任务切换到Solana时,我意识到得从头学起。核心的挑战很明确:我需要快速掌握@solana/web3.js这个官方SDK,用它来实现钱包连接、读取链上数据和发送交易这些基础但至关重要的功能。一开始我以为这和以太坊开发大同小异,结果一脚踩进了好几个坑里。

问题分析

我的第一反应是去翻@solana/web3.js的官方文档和示例。文档结构清晰,但当我试图把文档里的代码片段拼凑成一个完整的React应用时,问题来了。首先,钱包连接逻辑和以太坊的window.ethereum完全不同,Solana主流钱包如Phantom将接口注入到window.solana。其次,账户模型差异巨大:Solana使用公钥(PublicKey)作为地址,交易需要"最近区块哈希"和"手续费支付者"等概念,这让我一开始构建交易时屡屡失败。最初的几次尝试,不是钱包弹不出连接框,就是交易签名后发送失败,控制台报错信息又比较晦涩。我意识到,不能只是机械地复制代码,必须理解Solana交易构建的基本流程。

核心实现

1. 环境搭建与钱包连接

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

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

这里有个关键点:单纯用@solana/web3.js也能连接钱包,但社区更推荐使用@solana/wallet-adapter-*这一套工具库,它封装了连接逻辑和UI组件,能省不少事。

接下来,我设置钱包上下文。这是整个应用能调用钱包功能的基础:

typescript 复制代码
// 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-wallets';
import { clusterApiUrl } from '@solana/web3.js';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { MyComponent } from './MyComponent';

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

function App() {
  // 配置网络。开发时通常用devnet或testnet,这里用devnet
  const network = WalletAdapterNetwork.Devnet;
  const endpoint = useMemo(() => clusterApiUrl(network), [network]);

  // 配置支持的钱包列表
  const wallets = useMemo(
    () => [
      new PhantomWalletAdapter(),
      // 可以继续添加其他钱包适配器,如Solflare
    ],
    []
  );

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

export default App;

注意这个细节ConnectionProviderendpoint参数是必须的,它指定了你的应用要连接哪个Solana集群(主网、测试网等)。autoConnect属性会在页面加载时尝试重新连接上次的钱包,提升用户体验。

2. 获取钱包地址与余额

在子组件MyComponent中,我使用适配器提供的钩子来获取钱包状态和连接信息。

typescript 复制代码
// MyComponent.tsx
import React, { useState, useEffect } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { LAMPORTS_PER_SOL } from '@solana/web3.js';

export const MyComponent: 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 (connected && publicKey) {
        setLoading(true);
        try {
          // 注意:getBalance返回的是lamports,1 SOL = 10^9 lamports
          const lamportsBalance = await connection.getBalance(publicKey);
          setBalance(lamportsBalance / LAMPORTS_PER_SOL); // 转换为SOL单位
        } catch (error) {
          console.error('获取余额失败:', error);
          setBalance(null);
        } finally {
          setLoading(false);
        }
      } else {
        setBalance(null);
      }
    };

    fetchBalance();
  }, [connection, publicKey, connected]);

  return (
    <div>
      <p>钱包状态: {connected ? '已连接' : '未连接'}</p>
      {publicKey && <p>钱包地址: {publicKey.toBase58()}</p>}
      {loading && <p>查询余额中...</p>}
      {balance !== null && !loading && <p>余额: {balance} SOL</p>}
    </div>
  );
};

这里有个坑connection.getBalance()返回的单位是lamports,而不是SOL。直接显示这个数字会非常大,必须除以LAMPORTS_PER_SOL(10^9)来转换。我一开始没注意,显示了一个9位数的"余额",闹了笑话。

3. 构建并发送一笔SOL转账交易

这是最核心也最容易出错的部分。在Solana上,一笔交易可以包含多个指令,我们需要构建一个"系统程序"的转账指令。

typescript 复制代码
// 在MyComponent.tsx中添加发送交易函数
import { SystemProgram, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';

const sendTransaction = async () => {
  // 1. 基础校验
  if (!publicKey || !connected) {
    alert('请先连接钱包');
    return;
  }
  if (!connection) {
    alert('连接异常');
    return;
  }

  // 2. 构建交易指令
  // 假设我们向这个地址转账0.01 SOL
  const toPublicKey = new PublicKey('接收方的Solana地址(Base58格式)');
  const transferAmount = 0.01; // SOL
  const lamportsToSend = transferAmount * LAMPORTS_PER_SOL;

  const transferInstruction = SystemProgram.transfer({
    fromPubkey: publicKey,
    toPubkey: toPublicKey,
    lamports: lamportsToSend,
  });

  // 3. 创建交易并添加指令
  const transaction = new Transaction().add(transferInstruction);

  // 4. 获取"最近区块哈希"(Recent Blockhash)------这是Solana交易必需的
  let blockhash;
  try {
    const { blockhash: recentBlockhash } = await connection.getLatestBlockhash();
    blockhash = recentBlockhash;
    transaction.recentBlockhash = blockhash;
    // 5. 设置交易的费用支付者(Fee Payer)
    transaction.feePayer = publicKey;
  } catch (error) {
    console.error('获取区块哈希失败:', error);
    alert('获取网络信息失败,请重试');
    return;
  }

  // 6. 请求钱包签名并发送
  try {
    // 这里使用了wallet-adapter的signTransaction方法
    // 注意:在真实场景中,我们通常使用wallet-adapter提供的sendTransaction方法,它内部处理了签名和发送。
    // 但为了演示底层过程,这里先展示需要手动签名的流程,后面会给出更优方案。
    const signedTransaction = await signTransaction(transaction); // 假设signTransaction来自useWallet
    const signature = await connection.sendRawTransaction(signedTransaction.serialize());
    console.log('交易已发送,签名:', signature);

    // 7. 确认交易
    const confirmation = await connection.confirmTransaction(signature);
    if (confirmation.value.err) {
      throw new Error('交易确认失败');
    }
    alert(`转账成功!交易签名: ${signature}`);
  } catch (error: any) {
    console.error('发送交易失败:', error);
    alert(`交易失败: ${error.message}`);
  }
};

注意这个细节recentBlockhashfeePayer是Solana交易对象必须设置的两个属性,缺一不可。忘记设置feePayer是我遇到的第一个报错。recentBlockhash用于防止交易重放,并让验证者知道交易的有效期。

4. 使用Wallet Adapter优化交易发送

上面的手动签名流程比较繁琐,而且useWallet钩子并不直接暴露signTransaction方法。实际上,@solana/wallet-adapter-react提供了更优雅的sendTransaction方法。

typescript 复制代码
// 这是更推荐的实践,修改MyComponent.tsx
import { useConnection, useWallet } from '@solana/wallet-adapter-react';

const { connection } = useConnection();
const { publicKey, sendTransaction } = useWallet(); // 使用钩子提供的sendTransaction

const sendTransactionEasy = async () => {
  if (!publicKey) return;

  const toPublicKey = new PublicKey('接收方地址');
  const lamportsToSend = 0.01 * LAMPORTS_PER_SOL;

  const transaction = new Transaction().add(
    SystemProgram.transfer({
      fromPubkey: publicKey,
      toPubkey: toPublicKey,
      lamports: lamportsToSend,
    })
  );

  // 关键步骤:获取区块哈希并设置
  const { blockhash } = await connection.getLatestBlockhash();
  transaction.recentBlockhash = blockhash;
  transaction.feePayer = publicKey;

  try {
    // 一行代码搞定:钱包适配器会处理弹窗签名、发送、获取签名结果
    const signature = await sendTransaction(transaction, connection);
    console.log('交易签名:', signature);

    // 可选:等待交易确认
    const result = await connection.confirmTransaction(signature, 'confirmed');
    console.log('确认结果:', result);
    alert('转账成功!');
  } catch (error: any) {
    console.error('交易出错:', error);
    alert(`用户拒绝或交易失败: ${error.message}`);
  }
};

这里有个巨大的进步 :使用钱包适配器提供的sendTransaction方法,我们不需要手动处理签名、序列化、发送原始交易这些底层细节。它会自动触发钱包的签名请求,并返回交易签名。代码简洁且健壮。

完整代码

以下是一个整合了所有功能、可以直接运行的MyComponent.tsx示例:

typescript 复制代码
import React, { useState, useEffect } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { SystemProgram, Transaction, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';

export const MyComponent: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, connected, sendTransaction } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const [sending, setSending] = useState(false);
  const [recipient, setRecipient] = useState('');

  // 获取余额
  useEffect(() => {
    const fetchBalance = async () => {
      if (connected && publicKey) {
        setLoading(true);
        try {
          const lamportsBalance = await connection.getBalance(publicKey);
          setBalance(lamportsBalance / LAMPORTS_PER_SOL);
        } catch (error) {
          console.error('获取余额失败:', error);
          setBalance(null);
        } finally {
          setLoading(false);
        }
      } else {
        setBalance(null);
      }
    };
    fetchBalance();
  }, [connection, publicKey, connected]);

  // 发送SOL交易
  const handleSendSol = async () => {
    if (!publicKey || !recipient) {
      alert('请先连接钱包并填写接收地址');
      return;
    }
    let toPubkey;
    try {
      toPubkey = new PublicKey(recipient);
    } catch {
      alert('接收地址格式无效');
      return;
    }

    const transferAmount = 0.01; // 固定转账0.01 SOL,实际项目可以做成输入框
    const lamportsToSend = transferAmount * LAMPORTS_PER_SOL;

    const transaction = new Transaction().add(
      SystemProgram.transfer({
        fromPubkey: publicKey,
        toPubkey: toPubkey,
        lamports: lamportsToSend,
      })
    );

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

      setSending(true);
      const signature = await sendTransaction(transaction, connection);
      console.log('交易完成,签名:', signature);

      // 等待最终确认,提供更好反馈
      await connection.confirmTransaction(signature, 'confirmed');
      alert(`成功转账${transferAmount} SOL!交易签名: ${signature}`);
      setRecipient(''); // 清空输入框
      // 重新获取余额
      const newBalance = await connection.getBalance(publicKey);
      setBalance(newBalance / LAMPORTS_PER_SOL);
    } catch (error: any) {
      console.error('交易失败:', error);
      if (error.message.includes('User rejected')) {
        alert('您拒绝了交易签名。');
      } else {
        alert(`交易失败: ${error.message}`);
      }
    } finally {
      setSending(false);
    }
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h1>Solana Web3.js 入门实战</h1>
      <div style={{ marginBottom: '20px' }}>
        <WalletMultiButton />
      </div>

      {connected && publicKey && (
        <div>
          <p>
            <strong>钱包地址:</strong> {publicKey.toBase58()}
          </p>
          <p>
            <strong>余额:</strong>{' '}
            {loading ? '加载中...' : balance !== null ? `${balance.toFixed(4)} SOL` : '--'}
          </p>

          <hr style={{ margin: '20px 0' }} />

          <h3>发送 SOL 测试</h3>
          <div>
            <input
              type="text"
              placeholder="输入接收方Solana地址"
              value={recipient}
              onChange={(e) => setRecipient(e.target.value)}
              style={{ width: '400px', padding: '8px', marginRight: '10px' }}
            />
            <button onClick={handleSendSol} disabled={sending || !recipient}>
              {sending ? '发送中...' : '发送 0.01 SOL'}
            </button>
            <p style={{ fontSize: '0.9em', color: '#666', marginTop: '5px' }}>
              请确保在Devnet网络,并使用Devnet的SOL进行测试。
            </p>
          </div>
        </div>
      )}
      {!connected && <p>请点击上方按钮连接钱包(推荐Phantom)。</p>}
    </div>
  );
};

踩坑记录

  1. "Cannot read properties of undefined (reading 'solana')" :这是我遇到的第一个错误。原因是我在没有安装Phantom钱包(或任何Solana钱包)的浏览器中运行代码。window.solana对象不存在。解决方法:在代码中增加判断,或者引导用户安装钱包。钱包适配器的UI按钮会自动处理这个状态。

  2. "Transaction recentBlockhash required" :构建交易后发送失败。我忘记给交易对象transaction设置recentBlockhash属性。解决方法 :在发送交易前,必须调用connection.getLatestBlockhash()并赋值给transaction.recentBlockhash

  3. "FeePayer must be a PublicKey" :设置了recentBlockhash后依然报错。因为我连feePayer也没设置。解决方法 :将当前用户的公钥publicKey赋值给transaction.feePayer。记住,这两个属性是Solana Transaction对象的必选项。

  4. 交易签名成功但链上确认失败 :在测试网发送交易,钱包签名弹窗成功了,但最后交易失败。原因是我用的RPC节点不稳定或响应慢。解决方法 :更换更稳定、快速的RPC端点。对于开发,可以使用Solana基金会提供的公共端点clusterApiUrl('devnet'),但对于生产环境,需要考虑使用付费的私有RPC服务以获得更好的可靠性。

小结

通过这个从零到一的实践,我深刻体会到Solana前端开发在交易构建细节上与EVM的差异。核心收获是:理解Solana交易必须包含recentBlockhashfeePayer,并善用@solana/wallet-adapter系列工具库能极大提升开发效率。下一步,我可以基于此继续探索如何与SPL代币(类似ERC20)交互、如何解析NFT元数据,以及如何与自定义的智能合约(Solana上称为程序)进行交互。

相关推荐
陆枫Larry2 小时前
搞懂 package.json 和 package-lock.json
前端
Cache技术分享2 小时前
385. Java IO API - Chmod 示例:模拟 chmod 命令的文件权限更改
前端·后端
沙振宇2 小时前
【Web】使用Vue3+PlayCanvas开发3D游戏(十一)渲染3D高斯泼溅效果
前端·游戏·3d
cool32002 小时前
4D实验八:Dubbo微服务 + 注册中心
前端·kubernetes
军军君012 小时前
数字孪生监控大屏实战模板:商圈大数据监控
前端·javascript·vue.js·typescript·前端框架·echarts·three
方安乐2 小时前
try catch vs 异步捕获
前端·javascript·vue.js
chenbin___2 小时前
鸿蒙RN position: ‘absolute‘ 和 zIndex 的兼容性问题(转自千问)
前端·javascript·react native·harmonyos
晴天丨2 小时前
Vue 3项目架构设计:从2200行单文件到24个组件
前端·vue.js
blanks20202 小时前
为 Zed 编辑器 添加 flutter dart snippets
前端·flutter