从以太坊转战Solana:我用@solana/web3.js实现钱包连接与代币转账的踩坑实录

5 阅读9分钟

背景

最近,我加入了一个新的团队,负责开发一个基于Solana链的NFT铸造平台前端。作为一个有几年以太坊生态开发经验的“老兵”,我最初觉得这不过是换个链,把ethers.js换成@solana/web3.js,把MetaMask换成Phantom钱包而已。然而,真正上手后我才发现,Solana的开发范式与EVM链差异巨大,从账户模型到交易构建,处处是“惊喜”。项目第一个里程碑是实现用户钱包连接、显示SOL余额以及支持用户使用特定SPL代币支付铸造费用。这篇文章,就记录了我从零开始,用@solana/web3.js解决这些核心需求的完整过程。

问题分析

我的第一反应是去翻看官方文档和找一些现成的React Hook库(类似wagmi)。确实找到了@solana/wallet-adapter系列库,它提供了钱包连接的UI组件和上下文。但是,当我需要执行具体的链上操作,比如查询一个自定义SPL代币的余额、构建一笔转账交易时,我发现这些高阶库封装得太好了,反而让我对底层发生了什么一无所知。一旦遇到文档没覆盖的边缘情况,或者需要调试交易失败的原因时,我就完全束手无策。

我意识到,必须从底层@solana/web3.js开始,亲手构建和发送交易,才能真正理解Solana的前端开发。我的目标很明确:1. 连接Phantom钱包;2. 获取用户SOL和特定SPL代币余额;3. 构建并发送一笔SPL代币转账交易。最初的尝试是直接照搬以太坊的思路,结果在“账户”、“关联代币账户”、“交易指令”这些概念上撞得头破血流。

核心实现

第一步:搭建环境与连接钱包

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

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

@solana/wallet-adapter系列库负责钱包连接的UI和状态管理,而@solana/web3.js则是与链交互的核心工具包。这里我决定混合使用:用适配器库连接钱包,用web3.js执行所有链上操作,这样既能快速获得连接能力,又能深入理解交易细节。

我创建了一个WalletContextProvider组件来包裹应用,并初始化了主要的钱包适配器(这里以Phantom为例)。

// App.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';

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

function App() {
  const network = WalletAdapterNetwork.Devnet; // 使用开发网
  const endpoint = clusterApiUrl(network);
  const wallets = [new PhantomWalletAdapter()];

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

export default App;

在业务组件MySolanaDapp中,我使用useWallet钩子获取连接状态和公钥。

// MySolanaDapp.tsx
import React from 'react';
import { useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';

export const MySolanaDapp: React.FC = () => {
  const { publicKey, connected } = useWallet();

  return (
    <div>
      <WalletMultiButton />
      {connected && <p>钱包地址: {publicKey?.toBase58()}</p>}
    </div>
  );
};

这里有个坑@solana/wallet-adapter-react-ui的CSS样式需要单独导入,否则连接按钮的样式会错乱。另外,ConnectionProviderendpoint参数非常重要,它决定了你的应用连接哪个Solana集群(主网、测试网、开发网或自定义RPC)。项目初期务必使用Devnet(开发网),因为主网的SOL是真金白银。

第二步:获取SOL余额与创建连接对象

钱包连接成功后,下一步是获取用户的SOL余额。这需要用到@solana/web3.jsConnection对象。ConnectionProvider已经为我们创建了一个全局的连接对象,但为了更清晰地展示流程,我选择在组件内手动创建一次。

import { Connection, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { useWallet } from '@solana/wallet-adapter-react';
import { useEffect, useState } from 'react';

export const MySolanaDapp: React.FC = () => {
  const { publicKey, connected } = useWallet();
  const [solBalance, setSolBalance] = useState<number | null>(null);
  const [connection] = useState(() => new Connection(clusterApiUrl('devnet')));

  useEffect(() => {
    const fetchBalance = async () => {
      if (!publicKey) {
        setSolBalance(null);
        return;
      }
      try {
        // 获取余额,单位是Lamports(1 SOL = 10^9 Lamports)
        const balanceInLamports = await connection.getBalance(publicKey);
        // 转换为SOL单位
        const balanceInSOL = balanceInLamports / LAMPORTS_PER_SOL;
        setSolBalance(balanceInSOL);
      } catch (error) {
        console.error('获取SOL余额失败:', error);
        setSolBalance(null);
      }
    };

    fetchBalance();
  }, [publicKey, connection]); // 当公钥变化时重新获取

  return (
    <div>
      {/* ... 钱包连接按钮 ... */}
      {connected && (
        <div>
          <p>钱包地址: {publicKey?.toBase58()}</p>
          <p>SOL余额: {solBalance !== null ? `${solBalance.toFixed(4)} SOL` : '加载中...'}</p>
        </div>
      )}
    </div>
  );
};

这一步相对简单,和以太坊的provider.getBalance(address)很像。关键是要记住余额的单位是Lamports,需要除以LAMPORTS_PER_SOL(一个常量)来转换成我们熟悉的SOL

第三步:理解关联代币账户(ATA)并查询SPL代币余额

接下来是重头戏,也是我踩坑最多的地方:查询SPL代币(类似于ERC20)的余额。在Solana上,用户的代币并不直接存储在钱包的主账户(即我们刚才查SOL余额的账户)里。相反,每种代币都有一个独立的“关联代币账户”

每个关联代币账户(Associated Token Account, ATA)由钱包地址和代币的铸币地址(Mint Address)共同决定,并且可以通过一个确定性的算法计算出来。这意味着,用户可能还没有持有某种代币,也就没有对应的ATA。查询之前,我们需要先找到(或计算出)这个ATA的地址,然后查询它的余额。

import { Connection, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID, getAssociatedTokenAddress } from '@solana/spl-token'; // 注意这个新包
import { useEffect, useState } from 'react';

// 假设我们想查询的SPL代币的铸币地址(这里是USDC在Devnet的测试地址)
const USDC_MINT_DEVNET = new PublicKey('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');

export const MySolanaDapp: React.FC = () => {
  const { publicKey, connected } = useWallet();
  const [usdcBalance, setUsdcBalance] = useState<number | null>(null);
  const [connection] = useState(() => new Connection(clusterApiUrl('devnet')));

  useEffect(() => {
    const fetchTokenBalance = async () => {
      if (!publicKey) {
        setUsdcBalance(null);
        return;
      }
      try {
        // 1. 计算用户对于USDC代币的关联代币账户(ATA)地址
        const associatedTokenAddress = await getAssociatedTokenAddress(
          USDC_MINT_DEVNET, // 代币铸币地址
          publicKey, // 代币持有者(用户)地址
          false, // 是否允许所有者不是关联者?通常为false
          TOKEN_PROGRAM_ID,
          ASSOCIATED_TOKEN_PROGRAM_ID
        );

        // 2. 获取该ATA账户的信息
        const accountInfo = await connection.getAccountInfo(associatedTokenAddress);

        if (accountInfo === null) {
          // 账户不存在,意味着用户余额为0(或者从未持有过该代币)
          setUsdcBalance(0);
          return;
        }

        // 3. 解析ATA账户数据,获取余额(单位是代币的最小单位,例如USDC是6位小数)
        // 这里需要@solana/spl-token包中的`AccountLayout`来解析,更简单的方法是使用`getAccount`
        // 我们先安装并引入`spl-token`包
        import { getAccount } from '@solana/spl-token';
        const tokenAccount = await getAccount(connection, associatedTokenAddress);
        // tokenAccount.amount 是一个BigInt,表示基础单位的数量
        const balance = Number(tokenAccount.amount) / (10 ** 6); // 假设USDC是6位小数
        setUsdcBalance(balance);

      } catch (error) {
        console.error('获取USDC余额失败:', error);
        // 如果是因为ATA不存在而报错,可以视为余额0
        if (error instanceof Error && error.message.includes('Account does not exist')) {
          setUsdcBalance(0);
        } else {
          setUsdcBalance(null);
        }
      }
    };

    fetchTokenBalance();
  }, [publicKey, connection]);

  return (
    <div>
      {/* ... 其他UI ... */}
      {connected && (
        <div>
          <p>USDC余额: {usdcBalance !== null ? `${usdcBalance.toFixed(4)} USDC` : '加载中...'}</p>
        </div>
      )}
    </div>
  );
};

注意这个细节:我额外安装了@solana/spl-token包(npm install @solana/spl-token),它提供了与SPL代币交互的更高层级的工具函数,比如getAssociatedTokenAddressgetAccount,这比直接用web3.js的原始方法解析账户数据要方便和安全得多。另外,代币余额的单位也是坑,需要根据代币的小数位数(decimals)进行转换,这个信息通常存储在代币的元数据或铸币账户中,示例中我直接硬编码了USDC的6位小数。

第四步:构建并发送SPL代币转账交易

最后,也是最复杂的一步:让用户发送一笔USDC代币转账。在Solana中,一笔交易(Transaction)包含一个或多个指令(Instruction)。一个SPL代币转账指令至少需要以下几个步骤:

  1. 确保发送方和接收方的ATA存在(如果接收方的ATA不存在,可能需要先创建它)。
  2. 构建一个transferChecked指令(或transfer)。
  3. 将指令添加到交易中。
  4. 获取最近的区块哈希(作为交易的“门票”)。
  5. 用户(发送方)签名并发送交易。

由于创建接收方ATA的步骤会增加复杂性,我决定先实现一个前提:假设接收方已经拥有对应代币的ATA。在实际产品中,你需要处理ATA不存在的场景,通常是通过在转账交易中附加一个“创建ATA”的指令来完成。

import { Connection, PublicKey, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
import { getAssociatedTokenAddress, createTransferCheckedInstruction, getAccount } from '@solana/spl-token';
import { useWallet } from '@solana/wallet-adapter-react';
import { useState } from 'react';

const USDC_MINT_DEVNET = new PublicKey('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');

export const MySolanaDapp: React.FC = () => {
  const { publicKey, connected, signTransaction, sendTransaction } = useWallet();
  const [connection] = useState(() => new Connection(clusterApiUrl('devnet')));
  const [recipientAddress, setRecipientAddress] = useState('');
  const [transferAmount, setTransferAmount] = useState('');
  const [isSending, setIsSending] = useState(false);

  const handleTransfer = async () => {
    if (!publicKey || !recipientAddress || !transferAmount || isSending) return;
    setIsSending(true);

    try {
      // 1. 验证接收方地址格式
      const recipientPubkey = new PublicKey(recipientAddress);

      // 2. 获取发送方和接收方的ATA地址
      const fromATA = await getAssociatedTokenAddress(USDC_MINT_DEVNET, publicKey);
      const toATA = await getAssociatedTokenAddress(USDC_MINT_DEVNET, recipientPubkey);

      // 3. 检查发送方ATA是否存在且有足够余额(可选但推荐)
      const fromAccount = await getAccount(connection, fromATA);
      const amountInSmallestUnit = Math.floor(parseFloat(transferAmount) * (10 ** 6)); // 转换为最小单位
      if (fromAccount.amount < BigInt(amountInSmallestUnit)) {
        throw new Error('余额不足');
      }

      // 4. 构建转账指令
      const transferInstruction = createTransferCheckedInstruction(
        fromATA, // 来源ATA
        USDC_MINT_DEVNET, // 代币铸币地址
        toATA, // 目标ATA
        publicKey, // 代币的授权所有者(即发送方)
        amountInSmallestUnit, // 转账数量(最小单位)
        6 // 代币的小数位数
      );

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

      // 6. 获取最近区块哈希并设置为交易的最近区块引用(防止重放攻击)
      const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
      transaction.recentBlockhash = blockhash;
      transaction.feePayer = publicKey;

      // 7. 发送交易(使用钱包适配器的sendTransaction,它会处理签名和发送)
      // 注意:这里使用钱包适配器提供的`sendTransaction`,而不是`spl-token`或`web3.js`的
      const signature = await sendTransaction(transaction, connection);
      console.log('交易已发送,签名:', signature);

      // 8. 确认交易(等待区块链确认)
      const confirmation = await connection.confirmTransaction({
        signature,
        blockhash,
        lastValidBlockHeight,
      });
      if (confirmation.value.err) {
        throw new Error('交易确认失败');
      }
      alert(`转账成功!交易签名: ${signature}`);

    } catch (error: any) {
      console.error('转账失败:', error);
      alert(`转账失败: ${error.message}`);
    } finally {
      setIsSending(false);
      setTransferAmount(''); // 清空输入框
    }
  };

  return (
    <div>
      {/* ... 其他UI ... */}
      {connected && (
        <div>
          <h3>转账USDC</h3>
          <input
            type="text"
            placeholder="接收方Solana地址"
            value={recipientAddress}
            onChange={(e) => setRecipientAddress(e.target.value)}
          />
          <input
            type="number"
            step="0.000001"
            placeholder="转账数量"
            value={transferAmount}
            onChange={(e) => setTransferAmount(e.target.value)}
          />
          <button onClick={handleTransfer} disabled={isSending}>
            {isSending ? '发送中...' : '发送'}
          </button>
        </div>
      )}
    </div>
  );
};

这里有个巨大的坑:我一开始试图直接用@solana/web3.jssendAndConfirmTransaction函数,并手动调用钱包的signTransaction方法。但这样处理起来非常繁琐,而且容易出错。后来发现,@solana/wallet-adapter-react提供的useWallet钩子中的sendTransaction方法已经完美地封装了签名和发送的过程,它会自动弹出钱包(如Phantom)让用户确认并签名,这是最佳实践。务必使用这个sendTransaction,而不是自己手动处理签名。

另一个细节是recentBlockhashfeePayer必须设置,否则交易无效。feePayer通常是交易的发起者(即发送方)。

完整代码

以下是一个整合了以上所有功能的简化版完整组件代码,你可以直接复制到一个React TypeScript项目中运行(确保已安装所有依赖)。

// MySolanaDapp.tsx
import React, { useEffect, useState } from 'react';
import { Connection, PublicKey, clusterApiUrl } from '@solana/web3.js';
import { getAssociatedTokenAddress, createTransferCheckedInstruction, getAccount } from '@solana/spl-token';
import { useWallet } from '@solana/wallet-adapter-react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';

// 开发网USDC测试代币铸币地址
const USDC_MINT_DEVNET = new PublicKey('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const SOL_DECIMALS = 9;
const USDC_DECIMALS = 6;

export const MySolanaDapp: React.FC = () => {
  const { publicKey, connected, sendTransaction } = useWallet();
  const [connection] = useState(() => new Connection(clusterApiUrl('devnet'), 'confirmed'));
  const [solBalance, setSolBalance] = useState<number | null>(null);
  const [usdcBalance, setUsdcBalance] = useState<number | null>(null);
  const [recipientAddress, setRecipientAddress] = useState('');
  const [transferAmount, setTransferAmount] = useState('');
  const [isSending, setIsSending] = useState(false);

  // 获取SOL余额
  useEffect(() => {
    const fetchSolBalance = async () => {
      if (!publicKey) {
        setSolBalance(null);
        return;
      }
      try {
        const balanceInLamports = await connection.getBalance(publicKey);
        setSolBalance(balanceInLamports / 10 ** SOL_DECIMALS);
      } catch (error) {
        console.error('获取SOL余额失败:', error);
        setSolBalance(null);
      }
    };
    fetchSolBalance();
  }, [publicKey, connection]);

  // 获取USDC余额
  useEffect(() => {
    const fetchUsdcBalance = async () => {
      if (!publicKey) {
        setUsdcBalance(null);
        return;
      }
      try {
        const associatedTokenAddress = await getAssociatedTokenAddress(
          USDC_MINT_DEVNET,
          publicKey
        );
        const tokenAccount = await getAccount(connection, associatedTokenAddress);
        setUsdcBalance(Number(tokenAccount.amount) / 10 ** USDC_DECIMALS);
      } catch (error: any) {
        // 如果账户不存在,余额为0
        if (error.message?.includes('Account does not exist')) {
          setUsdcBalance(0);
        } else {
          console.error('获取USDC余额失败:', error);
          setUsdcBalance(null);
        }
      }
    };
    fetchUsdcBalance();
  }, [publicKey, connection]);

  // 处理USDC转账
  const handleTransfer = async () => {
    if (!publicKey || !recipientAddress || !transferAmount || isSending) return;
    setIsSending(true);

    try {
      const recipientPubkey = new PublicKey(recipientAddress);
      const fromATA = await getAssociatedTokenAddress(USDC_MINT_DEVNET, publicKey);
      const toATA = await getAssociatedTokenAddress(USDC_MINT_DEVNET, recipientPubkey);

      // 检查余额
      const fromAccount = await getAccount(connection, fromATA);
      const amountInSmallestUnit = Math.floor(parseFloat(transferAmount) * 10 ** USDC_DECIMALS);
      if (fromAccount.amount < BigInt(amountInSmallestUnit)) {
        throw new Error('USDC余额不足');
      }

      // 构建指令和交易
      const transferInstruction = createTransferCheckedInstruction(
        fromATA,
        USDC_MINT_DEVNET,
        toATA,
        publicKey,
        amountInSmallestUnit,
        USDC_DECIMALS
      );

      const transaction = new Transaction().add(transferInstruction);
      const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
      transaction.recentBlockhash = blockhash;
      transaction.feePayer = publicKey;

      // 通过钱包发送交易
      const signature = await sendTransaction(transaction, connection);
      console.log('交易签名:', signature);

      // 等待确认
      await connection.confirmTransaction({
        signature,
        blockhash,
        lastValidBlockHeight,
      });

      alert(`转账成功!签名: ${signature}`);
      setTransferAmount(''); // 清空输入
      // 刷新余额
      const newBalance = Number(fromAccount.amount) / 10 ** USDC_DECIMALS - parseFloat(transferAmount);
      setUsdcBalance(newBalance);

    } catch (error: any) {
      console.error('转账失败:', error);
      alert(`转账失败: ${error.message}`);
    } finally {
      setIsSending(false);
    }
  };

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

      {connected && publicKey && (
        <div>
          <h2>账户信息</h2>
          <p><strong>地址:</strong> {publicKey.toBase58()}</p>
          <p><strong>SOL余额:</strong> {solBalance !== null ? `${solBalance.toFixed(4)} SOL` : '加载中...'}</p>
          <p><strong>USDC余额:</strong> {usdcBalance !== null ? `${usdcBalance.toFixed(4)} USDC` : '加载中...'}</p>

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

          <h2>USDC转账</h2>
          <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '400px' }}>
            <input
              type="text"
              placeholder="接收方Solana地址"
              value={recipientAddress}
              onChange={(e) => setRecipientAddress(e.target.value)}
              style={{ padding: '8px' }}
            />
            <input
              type="number"
              step="0.000001"
              placeholder="转账数量 (USDC)"
              value={transferAmount}
              onChange={(e) => setTransferAmount(e.target.value)}
              style={{ padding: '8px' }}
            />
            <button
              onClick={handleTransfer}
              disabled={isSending || !recipientAddress || !transferAmount}
              style={{
                padding: '10px',
                backgroundColor: isSending ? '#ccc' : '#007bff',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                cursor: isSending ? 'not-allowed' : 'pointer'
              }}
            >
              {isSending ? '发送中...' : '发送 USDC'}
            </button>
            <p style={{ fontSize: '0.9em', color: '#666' }}>
              注意:请确保接收方地址有效,且已在Devnet持有USDC测试代币的关联账户。
            </p>
          </div>
        </div>
      )}
    </div>
  );
};

踩坑记录

  1. “Account does not exist” 错误当查询代币余额时:这是我遇到的第一个拦路虎。我直接用用户的主账户地址去查询代币余额,结果一直报错。后来才明白必须通过getAssociatedTokenAddress先计算出该代币的ATA地址。如果ATA不存在(用户从未持有过该代币),getAccount会抛出这个错误。解决方法:在错误处理中捕获此特定错误,并将余额视为0。

  2. 交易发送失败,提示“Blockhash not found”:我在构建交易时忘了设置recentBlockhashfeePayer。Solana交易需要这两个字段来防止重放攻击和支付手续费。解决方法:在发送交易前,务必调用connection.getLatestBlockhash()获取最新的区块哈希,并将其与feePayer一起设置到交易对象中。

  3. 手动签名流程复杂且容易出错:最初我尝试用connection.sendTransaction,然后自己从钱包获取签名,过程非常繁琐,还经常遇到签名不匹配的问题。解决方法:直接使用@solana/wallet-adapter-reactuseWallet钩子提供的sendTransaction方法。它内部处理了与钱包的交互、签名和发送,是官方推荐的方式。

  4. 代币金额单位混淆:和SOL一样,SPL代币的余额也有最小单位。我一开始直接把UI上输入的数值传给了指令,导致转账了巨大数量的代币(例如想转1个,实际转了10^6个)。解决方法:牢记代币的小数位数(decimals),在构建指令前,将UI输入的数量乘以10 ** decimals转换为最小单位整数。

小结

通过这次实战,我深刻体会到Solana编程的核心在于理解其账户模型和交易指令系统。从“以太坊思维”切换过来需要一些时间,但亲手用@solana/web3.js走通整个流程后,心里踏实多了。下一步,我可以继续探索如何动态创建关联代币账户、如何与智能合约(Solana上叫Program)交互,以及如何优化交易确认的体验。希望这篇记录也能帮你绕过我踩过的这些坑。