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

6 阅读1分钟

背景

上个月,我接了一个Solana生态NFT项目的前端开发工作。之前我的经验主要集中在以太坊生态,用的是ethers.jswagmi,整套流程已经很熟了。但这次客户明确要求项目部署在Solana上,我必须快速上手@solana/web3.js这个官方SDK。

项目需求很明确:一个简单的页面,用户能连接Phantom钱包,看到自己的SOL余额和持有的项目NFT,并能完成一次Mint操作。听起来和以太坊的DApp流程差不多,对吧?但真正动手才发现,从思维习惯到API设计,差异都不小。我花了差不多两天时间,才把核心链路跑通。这篇文章就是把这个“踩坑-学习-解决”的过程完整记录下来。

问题分析

一开始,我试图用以太坊的开发思维来套Solana。在以太坊里,核心对象是ProviderSigner,通过它们与链交互。我本能地在Solana的文档里寻找类似的概念。我发现@solana/web3.js里有一个Connection对象,它似乎负责与节点的RPC通信。那“Signer”呢?我找了半天,发现Solana里交易签名和钱包交互的逻辑,和以太坊很不一样。

我的第一个思路是:先创建一个Connection实例,然后想办法拿到用户的“签名者”身份。我写了下面这段初始代码:

import { Connection } from '@solana/web3.js';

const connection = new Connection('https://api.mainnet-beta.solana.com');

然后我就卡住了。在以太坊里,我可以通过window.ethereum直接唤起钱包,拿到signer。但在Solana里,我该怎么做?我查了Phantom钱包的文档,发现它向window对象注入的是solana对象,而不是一个统一的Provider。而且,这个对象如何与Connection配合生成可签名的交易,我完全没有头绪。

我意识到,我需要彻底理解Solana的几个核心概念:PublicKey(地址)、Transaction(交易结构)、以及钱包如何作为Signer参与。不能简单套用以太坊的模式。

核心实现

1. 连接Phantom钱包并获取PublicKey

在Solana中,用户的地址是一个PublicKey对象。与以太坊的十六进制字符串地址不同,它是一个包含椭圆曲线公钥信息的类。连接钱包的第一步,就是获取这个PublicKey

Phantom钱包的浏览器扩展会注入window.solana对象。这里有个关键点:Phantom的API设计是事件驱动的。你需要检查isPhantom属性,并监听connectdisconnect事件。

我创建了一个React组件,状态里存放公钥和连接状态:

import React, { useState, useEffect } from 'react';
import { PublicKey } from '@solana/web3.js';

// 声明全局的Solana钱包类型
interface PhantomWindow extends Window {
  solana?: any;
}
declare const window: PhantomWindow;

function WalletConnector() {
  const [publicKey, setPublicKey] = useState<PublicKey | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  // 检查钱包是否已安装
  const checkIfWalletInstalled = () => {
    return !!window.solana && window.solana.isPhantom;
  };

  // 连接钱包的核心函数
  const connectWallet = async () => {
    if (!checkIfWalletInstalled()) {
      alert('请安装Phantom钱包!');
      return;
    }

    try {
      // 这里有个坑:直接调用connect()可能被用户拒绝,需要try-catch
      const response = await window.solana.connect();
      // response.publicKey 是一个PublicKey对象
      setPublicKey(new PublicKey(response.publicKey.toString()));
      setIsConnected(true);
    } catch (err) {
      console.error('用户拒绝了连接请求:', err);
    }
  };

  // 组件加载时,检查是否已经连接过
  useEffect(() => {
    if (checkIfWalletInstalled() && window.solana.isConnected) {
      setPublicKey(new PublicKey(window.solana.publicKey.toString()));
      setIsConnected(true);
    }
  }, []);

  return (
    <div>
      {!isConnected ? (
        <button onClick={connectWallet}>连接Phantom钱包</button>
      ) : (
        <div>
          <p>已连接地址: {publicKey?.toBase58()}</p>
          <button onClick={() => window.solana.disconnect()}>断开连接</button>
        </div>
      )}
    </div>
  );
}

注意这个细节window.solana.publicKey在连接后可以直接访问,但最好还是通过connect()方法获取,因为它会处理权限请求。另外,PublicKey对象有toBase58()方法,可以转换成我们常见的字符串地址格式。

2. 建立RPC连接并查询余额

拿到PublicKey后,下一步就是通过RPC节点查询链上数据。这里需要创建Connection实例。我选择了Solana官方的主网RPC端点,但在实际项目中,强烈建议使用付费的RPC服务(如QuickNode、Helius),因为公共端点有速率限制,很容易在用户多的时候出问题。

查询余额使用connection.getBalance(),它返回的是以lamports为单位的余额。1 SOL = 10^9 lamports。

import { Connection, LAMPORTS_PER_SOL } from '@solana/web3.js';

// 创建连接
const connection = new Connection('https://api.mainnet-beta.solana.com');

// 在组件内查询余额的函数
const fetchBalance = async (publicKey: PublicKey) => {
  if (!publicKey) return;
  try {
    // 返回的是lamports,需要转换
    const balanceInLamports = await connection.getBalance(publicKey);
    const balanceInSOL = balanceInLamports / LAMPORTS_PER_SOL;
    console.log(`余额: ${balanceInSOL} SOL`);
    return balanceInSOL;
  } catch (error) {
    console.error('查询余额失败:', error);
  }
};

// 在useEffect中调用,当publicKey变化时
useEffect(() => {
  if (publicKey) {
    fetchBalance(publicKey);
  }
}, [publicKey]);

这里我踩了第一个性能坑:不要在组件的渲染函数中直接调用getBalance,也不要在没有防抖的情况下频繁调用(比如用setInterval)。因为每次调用都是一次网络请求,公共RPC端点很快会把你限流。正确的做法是在连接钱包后查询一次,或者由用户主动触发刷新。

3. 构建并发送一笔简单的转账交易

这是最复杂的一步。在Solana上构建一笔交易(Transaction),和以太坊有显著区别。一个Solana交易可以包含多个指令(Instructions),每个指令都有对应的程序ID(类似于智能合约地址)、账户列表和操作数据。

为了先跑通流程,我决定实现最简单的功能:从连接的钱包向另一个地址发送0.01 SOL。这需要用到SystemProgram.transfer这个内置指令。

import {
  Connection,
  PublicKey,
  Transaction,
  SystemProgram,
  sendAndConfirmTransaction,
} from '@solana/web3.js';

const sendTransaction = async (fromPublicKey: PublicKey) => {
  if (!window.solana) return;

  // 1. 创建一个新交易对象
  const transaction = new Transaction();

  // 2. 定义接收方地址(这里用了一个测试地址,实际应从输入框获取)
  const toPublicKey = new PublicKey('接收方的Solana地址(Base58格式)');

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

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

  // 5. 设置交易的必要参数:最近区块哈希(recent blockhash)
  // 这是Solana交易的一个安全特性,防止交易重放
  const { blockhash } = await connection.getRecentBlockhash();
  transaction.recentBlockhash = blockhash;
  // 设置付费账户(即支付交易手续费和转账的账户)
  transaction.feePayer = fromPublicKey;

  // 6. 关键步骤:让钱包签名并发送交易
  try {
    // 这里有个大坑:`sendAndConfirmTransaction` 在浏览器前端环境通常不直接使用
    // 因为它需要传入一个本地的签名者(Keypair),而我们的签名者是用户钱包。
    // 正确的方式是使用钱包提供的方法来签名和发送。
    const { signature } = await window.solana.signAndSendTransaction(transaction);
    console.log('交易签名:', signature);

    // 7. 确认交易
    const result = await connection.confirmTransaction(signature);
    console.log('交易确认状态:', result);
    if (result.value.err) {
      throw new Error('交易失败');
    }
    alert('转账成功!');
  } catch (error) {
    console.error('发送交易失败:', error);
    alert('交易被用户取消或失败');
  }
};

这里有个至关重要的坑:我最开始试图用sendAndConfirmTransaction(connection, transaction, [signer])这个函数,但很快发现我根本拿不到用户的私钥来创建本地signer(这是理所当然的,私钥永远不该离开钱包)。正确的模式是:前端构建好交易对象,然后交给钱包(通过window.solana.signAndSendTransaction)去签名和广播。这是与以太坊前端开发(signer.sendTransaction)一个很大的思维转换。

4. 处理网络费用和错误

在测试时,我遇到了交易失败,提示Blockhash not found。这是因为Solana交易中的recentBlockhash有过期时间(大约2分钟)。如果用户从弹出交易签名窗口到点击确认的时间过长,或者网络慢,这个区块哈希就可能失效。

解决方案是:在用户即将签名前(例如点击确认按钮的瞬间)再去获取最新的blockhash,而不是在构建交易时就获取。更好的做法是,监听钱包的签名过程,如果失败且原因是区块哈希过期,则自动用新的区块哈希更新交易,并让用户重新签名。

此外,手续费(Fee)由交易的第一位付款人(feePayer)支付,金额很小,但必须确保付款人账户有足够的SOL来支付。在交易模拟或发送前,最好先通过connection.getFeeForMessage估算一下手续费,并给用户提示。

完整代码

下面是一个整合了以上所有功能的简单React组件示例,你可以直接复制到一个React项目(如Create React App)中运行测试。记得先安装依赖:npm install @solana/web3.js

import React, { useState, useEffect } from 'react';
import {
  Connection,
  PublicKey,
  Transaction,
  SystemProgram,
  LAMPORTS_PER_SOL,
} from '@solana/web3.js';

// 扩展Window类型以包含Phantom
interface PhantomWindow extends Window {
  solana?: any;
}
declare const window: PhantomWindow;

const SOLANA_RPC_URL = 'https://api.devnet.solana.com'; // 为测试改用开发网

const SolanaBasicDApp: React.FC = () => {
  const [publicKey, setPublicKey] = useState<PublicKey | null>(null);
  const [balance, setBalance] = useState<number | null>(null);
  const [connection] = useState<Connection>(new Connection(SOLANA_RPC_URL));
  const [toAddress, setToAddress] = useState<string>('');
  const [sendAmount, setSendAmount] = useState<string>('0.01');
  const [txSignature, setTxSignature] = useState<string | null>(null);

  // 1. 检查并连接钱包
  const checkAndConnectWallet = async () => {
    if (!window.solana || !window.solana.isPhantom) {
      alert('请安装Phantom钱包!');
      return;
    }
    try {
      const response = await window.solana.connect();
      const pk = new PublicKey(response.publicKey.toString());
      setPublicKey(pk);
      // 连接后立即查询余额
      fetchBalance(pk);
    } catch (err) {
      console.error('连接失败:', err);
    }
  };

  // 2. 查询余额
  const fetchBalance = async (pk: PublicKey) => {
    try {
      const lamports = await connection.getBalance(pk);
      setBalance(lamports / LAMPORTS_PER_SOL);
    } catch (error) {
      console.error('查询余额失败:', error);
    }
  };

  // 3. 发送SOL
  const handleSendSol = async () => {
    if (!publicKey || !toAddress || !sendAmount) {
      alert('请填写完整信息并连接钱包');
      return;
    }
    if (!window.solana) return;

    try {
      // 构建交易
      const transaction = new Transaction();
      const toPublicKey = new PublicKey(toAddress);
      const lamports = parseFloat(sendAmount) * LAMPORTS_PER_SOL;

      transaction.add(
        SystemProgram.transfer({
          fromPubkey: publicKey,
          toPubkey: toPublicKey,
          lamports,
        })
      );

      // 关键:在发送前获取最新的区块哈希
      const { blockhash } = await connection.getRecentBlockhash();
      transaction.recentBlockhash = blockhash;
      transaction.feePayer = publicKey;

      // 让钱包签名并发送
      const { signature } = await window.solana.signAndSendTransaction(transaction);
      setTxSignature(signature);
      console.log('交易已发送,签名:', signature);

      // 等待交易确认(简单等待,生产环境应用更健壮的轮询)
      await connection.confirmTransaction(signature);
      alert('转账成功!');
      // 刷新余额
      fetchBalance(publicKey);
    } catch (error: any) {
      console.error('发送交易出错:', error);
      if (error.message.includes('rejected')) {
        alert('用户拒绝了交易签名');
      } else {
        alert(`交易失败: ${error.message}`);
      }
    }
  };

  // 初始化:检查是否已连接
  useEffect(() => {
    if (window.solana?.isPhantom && window.solana.isConnected) {
      const pk = new PublicKey(window.solana.publicKey.toString());
      setPublicKey(pk);
      fetchBalance(pk);
    }
  }, []);

  return (
    <div style={{ padding: '20px' }}>
      <h1>Solana 基础DApp</h1>
      {!publicKey ? (
        <button onClick={checkAndConnectWallet}>连接Phantom钱包</button>
      ) : (
        <div>
          <p><strong>已连接地址:</strong> {publicKey.toBase58()}</p>
          <p><strong>余额:</strong> {balance !== null ? `${balance.toFixed(4)} SOL` : '加载中...'}</p>
          <button onClick={() => fetchBalance(publicKey)}>刷新余额</button>
          <hr />
          <h3>发送SOL</h3>
          <div>
            <input
              type="text"
              placeholder="接收方地址"
              value={toAddress}
              onChange={(e) => setToAddress(e.target.value)}
              style={{ width: '400px', marginRight: '10px' }}
            />
            <input
              type="number"
              step="0.01"
              placeholder="金额 (SOL)"
              value={sendAmount}
              onChange={(e) => setSendAmount(e.target.value)}
              style={{ width: '100px', marginRight: '10px' }}
            />
            <button onClick={handleSendSol}>发送</button>
          </div>
          {txSignature && (
            <p>
              <strong>最近交易签名:</strong>{' '}
              <a
                href={`https://explorer.solana.com/tx/${txSignature}?cluster=devnet`}
                target="_blank"
                rel="noopener noreferrer"
              >
                {txSignature.slice(0, 8)}... (查看详情)
              </a>
            </p>
          )}
          <hr />
          <button onClick={() => window.solana.disconnect()}>断开钱包连接</button>
        </div>
      )}
    </div>
  );
};

export default SolanaBasicDApp;

运行前注意:将代码中的SOLANA_RPC_URL改为https://api.mainnet-beta.solana.com可连接主网,但测试时建议先用开发网或测试网,避免损失真实资产。同时,确保你的Phantom钱包已安装,并且网络切换到对应的网络(如Devnet)。

踩坑记录

  1. “sendAndConfirmTransaction” requires a signer 错误:这是我遇到的第一个拦路虎。我试图像以太坊一样,用SDK直接发送交易。解决方法:理解Solana的前端签名流程必须依赖钱包扩展(如Phantom)提供的signAndSendTransaction方法,前端SDK只负责构建交易,不负责持有私钥。

  2. 交易因Blockhash not found而失败:用户操作慢导致交易过期。解决方法:将获取recentBlockhash的时机尽可能推迟到用户确认前,并考虑实现交易重试逻辑,在检测到该错误时自动用新区块哈希更新交易并重新请求签名。

  3. 公共RPC端点速率限制:在开发时频繁刷新页面和查询余额,很快收到429错误。解决方法:对于正式项目,务必使用付费的RPC服务提供商。在开发阶段,可以适当增加查询间隔,或使用本地测试节点。

  4. Phantom钱包连接状态不同步:有时页面刷新后,组件状态isConnected为false,但钱包扩展实际已连接。解决方法:在组件初始化时(useEffect中),不仅检查window.solana.isPhantom,还要检查window.solana.isConnected,并据此同步前端状态。

小结

通过这个实战项目,我最大的收获是理解了Solana前端交互的“构建交易 -> 钱包签名 -> 广播确认”这一核心模式,它与以太坊的“Provider/Signer”模式有思维上的差异。@solana/web3.js的API虽然底层,但足够灵活。下一步,我可以在此基础上深入探索如何与自定义智能合约(Solana上叫Program)交互,处理更复杂的交易指令,以及优化交易确认的用户体验。